smolmachines 1.27.0 → 1.27.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.
package/README.md CHANGED
@@ -360,6 +360,23 @@ const { id } = await base.checkpoint();
360
360
  const a = await Machine.restoreCheckpoint(id, "project-a", undefined, { cacheDisk: { cache: "project-a" } });
361
361
  ```
362
362
 
363
+ ## Jobs: long commands that survive a dropped connection (cloud)
364
+
365
+ `exec` holds one HTTP request open for the whole command, so a network blip or
366
+ a long build can lose it. A job starts the command detached and returns its id
367
+ at once. Its output and exit code stay in the machine, and the machine does not
368
+ auto-stop while it runs:
369
+
370
+ ```ts
371
+ const job = await m.startJob(["make", "test"], { workdir: "/src", timeout: 3600 });
372
+ const done = await job.wait(); // polls; { state: "exited", exitCode }
373
+ const log = await job.readAll(); // all of stdout, byte-exact
374
+ const page = await job.output({ stream: "stderr", offset: 0 }); // then page.nextOffset
375
+ ```
376
+
377
+ `state` is `"lost"` if the machine stopped under the job. `job.cancel()` stops
378
+ it (exit 143), and `m.job(id)` picks a job up again from another process.
379
+
363
380
  ## Building from source
364
381
 
365
382
  This package's native core lives alongside it (Rust, `src/*.rs`) and links the
package/dist/errors.d.ts CHANGED
@@ -4,9 +4,30 @@
4
4
  * prefixed with a bracketed code, e.g. `"[KVM_UNAVAILABLE] …"`. We parse that
5
5
  * back into a typed hierarchy so callers can branch on `err.code` /`instanceof`.
6
6
  */
7
+ /** What the cloud API said about a failed request, beyond its message. */
8
+ export interface CloudErrorDetail {
9
+ /** HTTP status of the response. */
10
+ status: number;
11
+ /** The server's stable problem code, e.g. `machine_interrupted`. */
12
+ problemCode?: string | undefined;
13
+ /** Whether sending the same request again can succeed. */
14
+ retryable: boolean;
15
+ /** How long the server asked callers to wait before retrying (ms). */
16
+ retryAfterMs?: number | undefined;
17
+ }
7
18
  export declare class SmolError extends Error {
8
19
  readonly code: string;
9
- constructor(code: string, message: string);
20
+ /** HTTP status, for an error the cloud API returned. */
21
+ readonly status?: number | undefined;
22
+ /** The server's stable problem code, for an error the cloud API returned. */
23
+ readonly problemCode?: string | undefined;
24
+ /** Whether sending the same request again can succeed. A command whose
25
+ * machine went away (`MACHINE_INTERRUPTED`) is retryable, but it may have
26
+ * partly run, so the SDK leaves that retry to the caller. */
27
+ readonly retryable: boolean;
28
+ /** How long the server asked callers to wait before retrying (ms). */
29
+ readonly retryAfterMs?: number | undefined;
30
+ constructor(code: string, message: string, detail?: CloudErrorDetail);
10
31
  }
11
32
  /** The active backend can't serve this operation (e.g. volumes on local). */
12
33
  export declare class NotSupportedError extends SmolError {
package/dist/errors.js CHANGED
@@ -9,10 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.ExecutionError = exports.InvalidConfigError = exports.NotSupportedError = exports.SmolError = void 0;
10
10
  exports.wrapNativeError = wrapNativeError;
11
11
  class SmolError extends Error {
12
- constructor(code, message) {
12
+ constructor(code, message, detail) {
13
13
  super(message);
14
14
  this.name = new.target.name;
15
15
  this.code = code;
16
+ this.status = detail?.status;
17
+ this.problemCode = detail?.problemCode;
18
+ this.retryable = detail?.retryable ?? false;
19
+ this.retryAfterMs = detail?.retryAfterMs;
16
20
  }
17
21
  }
18
22
  exports.SmolError = SmolError;
package/dist/index.d.ts CHANGED
@@ -16,10 +16,10 @@
16
16
  * Runs against the local embedded engine (default, no server) or the smolfleet
17
17
  * cloud — same API, backend selected via ConnectOptions / SMOL_CLOUD_TOKEN.
18
18
  */
19
- export { Machine, Episode, CacheDisk } from './machine';
19
+ export { Machine, Episode, CacheDisk, Job } from './machine';
20
20
  export { RolloutClient, RolloutError, adapterSha256 } from './rollout';
21
21
  export type { BuildRolloutJobOptions, EnsureRolloutExecutorOptions, RolloutBatchItem, RolloutClientOptions, RolloutCompletion, RolloutExecutorInfo, RolloutGenerateResponse, RolloutJob, RolloutPolicyInfo, RolloutPrompt, RolloutSampling, } from './rollout';
22
22
  export { SmolError, NotSupportedError, InvalidConfigError, ExecutionError } from './errors';
23
23
  export type { CloudCheckpointInfo } from './transport';
24
24
  export type { LocalAvailability, LocalUnavailableCode } from './availability';
25
- export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, ResumeOptions, WaitReadyOptions, PortEndpoint, } from './types';
25
+ export type { MachineConfig, CredentialSpec, CacheDiskRef, CacheDiskInfo, CacheDiskVersion, CreateCacheDiskOptions, PublishedCacheDisk, NetworkPolicy, RestoreCheckpointOptions, MachineState, ResourceSpec, MountSpec, PortSpec, BranchOptions, BranchBatchOptions, CheckpointOptions, ResizeOptions, MachineResources, ForkOptions, ForkBatchOptions, AssignOptions, ExecOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobState, JobWaitOptions, ExecResult, MachineUsageReport, ShareLink, PortableCheckpointInfo, MachineUsageTotals, MachineCostBreakdown, ImageInfo, MachineSummary, ListOptions, ExecEvent, ConnectOptions, EgressInterceptor, StartOptions, ResumeOptions, WaitReadyOptions, PortEndpoint, } from './types';
package/dist/index.js CHANGED
@@ -18,11 +18,12 @@
18
18
  * cloud — same API, backend selected via ConnectOptions / SMOL_CLOUD_TOKEN.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.ExecutionError = exports.InvalidConfigError = exports.NotSupportedError = exports.SmolError = exports.adapterSha256 = exports.RolloutError = exports.RolloutClient = exports.CacheDisk = exports.Episode = exports.Machine = void 0;
21
+ exports.ExecutionError = exports.InvalidConfigError = exports.NotSupportedError = exports.SmolError = exports.adapterSha256 = exports.RolloutError = exports.RolloutClient = exports.Job = exports.CacheDisk = exports.Episode = exports.Machine = void 0;
22
22
  var machine_1 = require("./machine");
23
23
  Object.defineProperty(exports, "Machine", { enumerable: true, get: function () { return machine_1.Machine; } });
24
24
  Object.defineProperty(exports, "Episode", { enumerable: true, get: function () { return machine_1.Episode; } });
25
25
  Object.defineProperty(exports, "CacheDisk", { enumerable: true, get: function () { return machine_1.CacheDisk; } });
26
+ Object.defineProperty(exports, "Job", { enumerable: true, get: function () { return machine_1.Job; } });
26
27
  var rollout_1 = require("./rollout");
27
28
  Object.defineProperty(exports, "RolloutClient", { enumerable: true, get: function () { return rollout_1.RolloutClient; } });
28
29
  Object.defineProperty(exports, "RolloutError", { enumerable: true, get: function () { return rollout_1.RolloutError; } });
package/dist/machine.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { type LocalAvailability } from "./availability";
11
11
  import { type CloudCheckpointInfo, type Transport } from "./transport";
12
- import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, ResumeOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
12
+ import type { CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, AssignOptions, BranchBatchOptions, BranchOptions, CheckpointOptions, ConnectOptions, ExecEvent, ExecOptions, ExecResult, ForkBatchOptions, ForkOptions, ImageInfo, JobInfo, JobOptions, JobOutput, JobOutputOptions, JobWaitOptions, ListOptions, MachineConfig, MachineSummary, NetworkPolicy, RestoreCheckpointOptions, StartOptions, ResumeOptions, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
13
13
  export declare class Machine {
14
14
  private readonly transport;
15
15
  private constructor();
@@ -101,6 +101,21 @@ export declare class Machine {
101
101
  * namespace, re-deployable anywhere. Distinct from a portable checkpoint,
102
102
  * which also keeps live memory. Cloud only. */
103
103
  exportArtifact(): Promise<Record<string, unknown>>;
104
+ /** Start `command` as a detached job and return at once. Its output and exit
105
+ * code are kept in the machine, so the caller can disconnect and come back:
106
+ * poll with {@link Job.wait}, read output from an offset with
107
+ * {@link Job.output}. The machine does not auto-stop while the job runs.
108
+ * A string runs through `sh -c`; the image needs `sh`. Cloud only.
109
+ *
110
+ * Unlike `exec`, a job survives a dropped connection, so prefer it for
111
+ * anything that runs longer than a few minutes. */
112
+ startJob(command: string[] | string, opts?: JobOptions): Promise<Job>;
113
+ /** This machine's jobs, newest first, as last recorded (up to 100). Use
114
+ * {@link Job.info} for a job's live state. Cloud only. */
115
+ jobs(): Promise<JobInfo[]>;
116
+ /** A handle to an existing job by id, for example one started by another
117
+ * process. Makes no request. Cloud only. */
118
+ job(id: string): Job;
104
119
  /** Whether the machine is READY to do work. `state()` becoming "started"
105
120
  * means only that the VM process launched — the guest is still booting and
106
121
  * is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
@@ -258,6 +273,27 @@ export declare class Machine {
258
273
  * @returns the grader's `ExecResult` (use `.success` for pass/fail, `.stdout` for a score) */
259
274
  rewardFork(command: string[], opts?: ExecOptions, name?: string): Promise<ExecResult>;
260
275
  }
276
+ /** A detached command in a cloud machine, from {@link Machine.startJob}. */
277
+ export declare class Job {
278
+ #private;
279
+ readonly id: string;
280
+ constructor(transport: Transport, id: string);
281
+ /** The job's live state. A running job's machine is asked whether it has
282
+ * finished; `lost` means the machine stopped or restarted under it. */
283
+ info(): Promise<JobInfo>;
284
+ /** A slice of stdout (default) or stderr from `offset`. Pass the returned
285
+ * `nextOffset` back to read on; `eof` is true once the job has finished and
286
+ * the slice reaches the end. */
287
+ output(opts?: JobOutputOptions): Promise<JobOutput>;
288
+ /** Everything written to `stream` so far. On a finished job, that is all
289
+ * of it. */
290
+ readAll(stream?: "stdout" | "stderr"): Promise<Buffer>;
291
+ /** Stop the job with SIGTERM. It is recorded as exited with code 143. */
292
+ cancel(): Promise<JobInfo>;
293
+ /** Poll until the job is no longer running and return its final state:
294
+ * `exited` with `exitCode`, or `lost`. */
295
+ wait(opts?: JobWaitOptions): Promise<JobInfo>;
296
+ }
261
297
  /** A leased RL episode: a freshly-provisioned clone plus its lease. Created by
262
298
  * `Machine.assign()`. Always `complete()` it (e.g. in a `finally`) so the clone
263
299
  * is reclaimed — the RL "one clean worker per task, guaranteed reclaimed"
package/dist/machine.js CHANGED
@@ -19,9 +19,9 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
19
19
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
20
20
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
21
21
  };
22
- var _Episode_machine, _Episode_ownerToken, _Episode_leaseTransport, _Episode_completed;
22
+ var _Job_transport, _Episode_machine, _Episode_ownerToken, _Episode_leaseTransport, _Episode_completed;
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.CacheDisk = exports.Episode = exports.Machine = void 0;
24
+ exports.CacheDisk = exports.Episode = exports.Job = exports.Machine = void 0;
25
25
  const node_crypto_1 = require("node:crypto");
26
26
  const node_path_1 = require("node:path");
27
27
  const errors_1 = require("./errors");
@@ -200,6 +200,34 @@ class Machine {
200
200
  throw new errors_1.NotSupportedError("exportArtifact is cloud-only.");
201
201
  return this.transport.exportArtifact();
202
202
  }
203
+ /** Start `command` as a detached job and return at once. Its output and exit
204
+ * code are kept in the machine, so the caller can disconnect and come back:
205
+ * poll with {@link Job.wait}, read output from an offset with
206
+ * {@link Job.output}. The machine does not auto-stop while the job runs.
207
+ * A string runs through `sh -c`; the image needs `sh`. Cloud only.
208
+ *
209
+ * Unlike `exec`, a job survives a dropped connection, so prefer it for
210
+ * anything that runs longer than a few minutes. */
211
+ async startJob(command, opts = {}) {
212
+ if (!this.transport.startJob)
213
+ throw new errors_1.NotSupportedError("startJob is cloud-only.");
214
+ const info = await this.transport.startJob(command, opts);
215
+ return new Job(this.transport, info.id);
216
+ }
217
+ /** This machine's jobs, newest first, as last recorded (up to 100). Use
218
+ * {@link Job.info} for a job's live state. Cloud only. */
219
+ jobs() {
220
+ if (!this.transport.listJobs)
221
+ throw new errors_1.NotSupportedError("jobs is cloud-only.");
222
+ return this.transport.listJobs();
223
+ }
224
+ /** A handle to an existing job by id, for example one started by another
225
+ * process. Makes no request. Cloud only. */
226
+ job(id) {
227
+ if (!this.transport.getJob)
228
+ throw new errors_1.NotSupportedError("job is cloud-only.");
229
+ return new Job(this.transport, id);
230
+ }
203
231
  /** Whether the machine is READY to do work. `state()` becoming "started"
204
232
  * means only that the VM process launched — the guest is still booting and
205
233
  * is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
@@ -438,6 +466,75 @@ class Machine {
438
466
  }
439
467
  }
440
468
  exports.Machine = Machine;
469
+ /** A detached command in a cloud machine, from {@link Machine.startJob}. */
470
+ class Job {
471
+ constructor(transport, id) {
472
+ _Job_transport.set(this, void 0);
473
+ __classPrivateFieldSet(this, _Job_transport, transport, "f");
474
+ this.id = id;
475
+ }
476
+ /** The job's live state. A running job's machine is asked whether it has
477
+ * finished; `lost` means the machine stopped or restarted under it. */
478
+ info() {
479
+ return __classPrivateFieldGet(this, _Job_transport, "f").getJob(this.id);
480
+ }
481
+ /** A slice of stdout (default) or stderr from `offset`. Pass the returned
482
+ * `nextOffset` back to read on; `eof` is true once the job has finished and
483
+ * the slice reaches the end. */
484
+ output(opts = {}) {
485
+ return __classPrivateFieldGet(this, _Job_transport, "f").jobOutput(this.id, opts);
486
+ }
487
+ /** Everything written to `stream` so far. On a finished job, that is all
488
+ * of it. */
489
+ async readAll(stream = "stdout") {
490
+ const chunks = [];
491
+ let offset = 0;
492
+ for (;;) {
493
+ const out = await this.output({ stream, offset, limit: 1024 * 1024 });
494
+ chunks.push(out.data);
495
+ offset = out.nextOffset;
496
+ if (out.eof || out.data.length === 0)
497
+ break;
498
+ }
499
+ return Buffer.concat(chunks);
500
+ }
501
+ /** Stop the job with SIGTERM. It is recorded as exited with code 143. */
502
+ cancel() {
503
+ return __classPrivateFieldGet(this, _Job_transport, "f").cancelJob(this.id);
504
+ }
505
+ /** Poll until the job is no longer running and return its final state:
506
+ * `exited` with `exitCode`, or `lost`. */
507
+ async wait(opts = {}) {
508
+ const pollMs = opts.pollMs ?? 1000;
509
+ const deadline = opts.timeoutMs === undefined ? Infinity : Date.now() + opts.timeoutMs;
510
+ for (;;) {
511
+ opts.signal?.throwIfAborted();
512
+ const info = await this.info();
513
+ if (info.state !== "running")
514
+ return info;
515
+ const left = deadline - Date.now();
516
+ if (left <= 0) {
517
+ throw new errors_1.SmolError("TIMEOUT", `job ${this.id} still running after ${opts.timeoutMs}ms`);
518
+ }
519
+ await sleep(Math.min(pollMs, left), opts.signal);
520
+ }
521
+ }
522
+ }
523
+ exports.Job = Job;
524
+ _Job_transport = new WeakMap();
525
+ function sleep(ms, signal) {
526
+ return new Promise((resolve, reject) => {
527
+ const timer = setTimeout(() => {
528
+ signal?.removeEventListener("abort", onAbort);
529
+ resolve();
530
+ }, ms);
531
+ const onAbort = () => {
532
+ clearTimeout(timer);
533
+ reject(signal?.reason);
534
+ };
535
+ signal?.addEventListener("abort", onAbort, { once: true });
536
+ });
537
+ }
441
538
  /** A leased RL episode: a freshly-provisioned clone plus its lease. Created by
442
539
  * `Machine.assign()`. Always `complete()` it (e.g. in a `finally`) so the clone
443
540
  * is reclaimed — the RL "one clean worker per task, guaranteed reclaimed"
@@ -7,7 +7,7 @@
7
7
  * Cloud-only/local-only capability gaps surface as `NotSupportedError`.
8
8
  */
9
9
  import { type NativeMachineConfig } from "./native";
10
- import type { CheckpointOptions, CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, ConnectOptions, NetworkPolicy, RestoreCheckpointOptions, ExecEvent, ExecOptions, EgressInterceptor, AssignOptions, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
10
+ import type { CheckpointOptions, JobInfo, JobOptions, JobOutput, JobOutputOptions, CacheDiskInfo, CreateCacheDiskOptions, PublishedCacheDisk, MachineResources, ResizeOptions, ConnectOptions, NetworkPolicy, RestoreCheckpointOptions, ExecEvent, ExecOptions, EgressInterceptor, AssignOptions, ForkBatchOptions, ForkOptions, ImageInfo, ListOptions, MachineConfig, MachineSummary, MachineUsageReport, ShareLink, PortableCheckpointInfo, PortEndpoint, PortSpec, WaitReadyOptions } from "./types";
11
11
  /** Raw exec result (the ergonomic wrapper is added in machine.ts). */
12
12
  export interface RawExec {
13
13
  /** The detached process's pid, for a background exec. */
@@ -31,6 +31,16 @@ export interface Transport {
31
31
  logs?(tail: number): Promise<string>;
32
32
  /** Cloud only: export the stopped machine as a `.smolmachine` in the registry. */
33
33
  exportArtifact?(): Promise<Record<string, unknown>>;
34
+ /** Cloud only: start a detached job. */
35
+ startJob?(command: string[] | string, opts: JobOptions): Promise<JobInfo>;
36
+ /** Cloud only: the machine's jobs, newest first. */
37
+ listJobs?(): Promise<JobInfo[]>;
38
+ /** Cloud only: one job's live state. */
39
+ getJob?(jobId: string): Promise<JobInfo>;
40
+ /** Cloud only: a slice of a job's stdout or stderr. */
41
+ jobOutput?(jobId: string, opts: JobOutputOptions): Promise<JobOutput>;
42
+ /** Cloud only: stop a job with SIGTERM. */
43
+ cancelJob?(jobId: string): Promise<JobInfo>;
34
44
  state(): Promise<string>;
35
45
  ready(): Promise<boolean>;
36
46
  readyAt(): Promise<string | null>;
@@ -122,6 +132,8 @@ interface CloudConn {
122
132
  /** The caller's `fetch` (ConnectOptions.fetch), when it supplied one. */
123
133
  fetch?: typeof fetch;
124
134
  readResponseBytes?: (response: Response) => Promise<Uint8Array>;
135
+ /** ConnectOptions.maxRetries, when the caller set it. */
136
+ maxRetries?: number;
125
137
  }
126
138
  /** The largest file the cloud's file routes accept. */
127
139
  export declare const CLOUD_MAX_FILE_BYTES: number;
@@ -140,6 +152,12 @@ export declare function errorSentence(body: string): string;
140
152
  * files route is a wildcard (`/files/<path>`), so slashes are meaningful while
141
153
  * spaces/?/#/% in a filename must be escaped. */
142
154
  export declare function encodePath(p: string): string;
155
+ /** Parse a `Retry-After` header (delta-seconds or an HTTP date) into ms. */
156
+ export declare function retryAfterMs(header: string | null, now?: number): number | undefined;
157
+ /** Whether the SDK may resend a request on its own. A rate-limited request
158
+ * never ran, so it is always safe; an unavailable one may have, so only a
159
+ * call that is safe to repeat is sent again. */
160
+ export declare function sdkMayRetry(status: number, method: string, idempotent: boolean): boolean;
143
161
  /** Does this request target the cloud?
144
162
  *
145
163
  * Only an EXPLICIT credential may select cloud: `conn.apiKey` and
package/dist/transport.js CHANGED
@@ -20,6 +20,8 @@ exports.transferTimeoutMs = transferTimeoutMs;
20
20
  exports.checkCloudFileSize = checkCloudFileSize;
21
21
  exports.errorSentence = errorSentence;
22
22
  exports.encodePath = encodePath;
23
+ exports.retryAfterMs = retryAfterMs;
24
+ exports.sdkMayRetry = sdkMayRetry;
23
25
  exports.selectsCloud = selectsCloud;
24
26
  exports.makeTransport = makeTransport;
25
27
  exports.listMachines = listMachines;
@@ -702,6 +704,7 @@ function cloudConnOf(conn, baseUrl, apiKey) {
702
704
  apiKey,
703
705
  ...(conn.fetch ? { fetch: conn.fetch } : {}),
704
706
  ...(conn.readResponseBytes ? { readResponseBytes: conn.readResponseBytes } : {}),
707
+ ...(conn.maxRetries !== undefined ? { maxRetries: conn.maxRetries } : {}),
705
708
  };
706
709
  }
707
710
  const DEFAULT_CLOUD_URL = "https://api.smolmachines.com";
@@ -765,7 +768,110 @@ function errorSentence(body) {
765
768
  function encodePath(p) {
766
769
  return p.split("/").map(encodeURIComponent).join("/");
767
770
  }
771
+ /** The typed error for a failed cloud response, carrying what the problem
772
+ * body and headers say about retrying. */
773
+ async function cloudError(res, what) {
774
+ const raw = await res.text().catch(() => "");
775
+ const text = errorSentence(raw);
776
+ const problem = problemOf(raw);
777
+ // Surface the server's correlation id (every response carries `x-request-id`)
778
+ // in the error message — clients see the error body but not headers, so
779
+ // without this the id is invisible and support can't correlate the call.
780
+ const rid = res.headers.get("x-request-id");
781
+ const code = problem.code === "machine_interrupted"
782
+ ? "MACHINE_INTERRUPTED"
783
+ : res.status === 404
784
+ ? "NOT_FOUND"
785
+ : res.status === 401
786
+ ? "UNAUTHORIZED"
787
+ : "SMOLVM_ERROR";
788
+ return new errors_1.SmolError(code, `${what} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`, {
789
+ status: res.status,
790
+ problemCode: problem.code,
791
+ retryable: problem.retryable ?? retryableStatus(res.status),
792
+ retryAfterMs: retryAfterMs(res.headers.get("retry-after")),
793
+ });
794
+ }
795
+ /** Retries for a retryable response when the caller sets none. */
796
+ const DEFAULT_CLOUD_RETRIES = 3;
797
+ /** Longest single wait between retries (ms), whatever `Retry-After` asks. */
798
+ const MAX_RETRY_WAIT_MS = 30_000;
799
+ /** Parse a `Retry-After` header (delta-seconds or an HTTP date) into ms. */
800
+ function retryAfterMs(header, now = Date.now()) {
801
+ if (!header)
802
+ return undefined;
803
+ const trimmed = header.trim();
804
+ if (/^\d+$/.test(trimmed))
805
+ return Number(trimmed) * 1_000;
806
+ const at = Date.parse(trimmed);
807
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
808
+ }
809
+ /** What a failed response's problem body says, beyond its sentence. */
810
+ function problemOf(body) {
811
+ try {
812
+ const parsed = JSON.parse(body.trim());
813
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
814
+ const fields = parsed;
815
+ return {
816
+ ...(typeof fields.code === "string" ? { code: fields.code } : {}),
817
+ ...(typeof fields.retryable === "boolean" ? { retryable: fields.retryable } : {}),
818
+ };
819
+ }
820
+ }
821
+ catch {
822
+ // Not a problem document.
823
+ }
824
+ return {};
825
+ }
826
+ /** Statuses the server uses for "try again": rate limited or briefly unavailable. */
827
+ function retryableStatus(status) {
828
+ return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
829
+ }
830
+ /** Whether the SDK may resend a request on its own. A rate-limited request
831
+ * never ran, so it is always safe; an unavailable one may have, so only a
832
+ * call that is safe to repeat is sent again. */
833
+ function sdkMayRetry(status, method, idempotent) {
834
+ if (status === 429)
835
+ return true;
836
+ if (!retryableStatus(status))
837
+ return false;
838
+ return idempotent || method === "GET" || method === "HEAD" || method === "PUT" || method === "DELETE";
839
+ }
768
840
  async function cloudFetch(conn, method, path, opts = {}) {
841
+ const retries = Math.max(0, conn.maxRetries ?? DEFAULT_CLOUD_RETRIES);
842
+ for (let attempt = 0;; attempt++) {
843
+ try {
844
+ return await cloudFetchOnce(conn, method, path, opts);
845
+ }
846
+ catch (error) {
847
+ if (!(error instanceof errors_1.SmolError) ||
848
+ attempt >= retries ||
849
+ error.status === undefined ||
850
+ !sdkMayRetry(error.status, method, opts.idempotent ?? false)) {
851
+ throw error;
852
+ }
853
+ const backoff = 500 * 2 ** attempt;
854
+ const wait = Math.min(error.retryAfterMs ?? backoff, MAX_RETRY_WAIT_MS);
855
+ await sleepUnlessAborted(wait, opts.signal);
856
+ }
857
+ }
858
+ }
859
+ /** Sleep `ms`, rejecting with the abort reason if `signal` fires first. */
860
+ function sleepUnlessAborted(ms, signal) {
861
+ signal?.throwIfAborted();
862
+ return new Promise((resolve, reject) => {
863
+ const timer = setTimeout(() => {
864
+ signal?.removeEventListener("abort", onAbort);
865
+ resolve();
866
+ }, ms);
867
+ const onAbort = () => {
868
+ clearTimeout(timer);
869
+ reject(signal?.reason);
870
+ };
871
+ signal?.addEventListener("abort", onAbort, { once: true });
872
+ });
873
+ }
874
+ async function cloudFetchOnce(conn, method, path, opts) {
769
875
  const headers = {
770
876
  authorization: `Bearer ${conn.apiKey}`,
771
877
  };
@@ -808,18 +914,8 @@ async function cloudFetch(conn, method, path, opts = {}) {
808
914
  clearTimeout(timer);
809
915
  opts.signal?.removeEventListener("abort", onAbort);
810
916
  }
811
- if (!res.ok) {
812
- const text = errorSentence(await res.text().catch(() => ""));
813
- // Surface the server's correlation id (every response carries `x-request-id`)
814
- // in the error message — clients see the error body but not headers, so
815
- // without this the id is invisible and support can't correlate the call.
816
- const rid = res.headers.get("x-request-id");
817
- throw new errors_1.SmolError(res.status === 404
818
- ? "NOT_FOUND"
819
- : res.status === 401
820
- ? "UNAUTHORIZED"
821
- : "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
822
- }
917
+ if (!res.ok)
918
+ throw await cloudError(res, `cloud ${method} ${path}`);
823
919
  if (opts.accept === "response")
824
920
  return res;
825
921
  if (opts.accept === "text")
@@ -1080,15 +1176,8 @@ class CloudTransport {
1080
1176
  throw signal.reason;
1081
1177
  throw new errors_1.SmolError("CONNECTION", `cloud exec/stream failed: ${e.message}`);
1082
1178
  }
1083
- if (!res.ok) {
1084
- const text = errorSentence(await res.text().catch(() => ""));
1085
- const rid = res.headers.get("x-request-id");
1086
- throw new errors_1.SmolError(res.status === 404
1087
- ? "NOT_FOUND"
1088
- : res.status === 401
1089
- ? "UNAUTHORIZED"
1090
- : "SMOLVM_ERROR", `cloud POST /v1/machines/${this.id}/exec/stream → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
1091
- }
1179
+ if (!res.ok)
1180
+ throw await cloudError(res, `cloud POST /v1/machines/${this.id}/exec/stream`);
1092
1181
  if (!res.body)
1093
1182
  throw new errors_1.SmolError("SMOLVM_ERROR", "exec/stream returned no response body");
1094
1183
  // Parse the server's SSE stream: each event is `event: <kind>` + one or more
@@ -1242,13 +1331,16 @@ class CloudTransport {
1242
1331
  throw new errors_1.NotSupportedError("setNetworkPolicy() is local-only.");
1243
1332
  }
1244
1333
  async stop() {
1245
- await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/stop`);
1334
+ await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/stop`, { idempotent: true });
1246
1335
  }
1247
1336
  async pause() {
1248
1337
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/pause`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1249
1338
  }
1250
1339
  async resume(waitUntilReady = true) {
1251
- await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
1340
+ await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, {
1341
+ timeoutMs: CLOUD_START_TIMEOUT_MS,
1342
+ idempotent: true,
1343
+ });
1252
1344
  if (waitUntilReady)
1253
1345
  await waitForReady(this.conn, this.id);
1254
1346
  }
@@ -1258,6 +1350,7 @@ class CloudTransport {
1258
1350
  // Resume a stopped machine, then wait for its agent so the handle is usable.
1259
1351
  await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/start`, {
1260
1352
  timeoutMs: CLOUD_START_TIMEOUT_MS,
1353
+ idempotent: true,
1261
1354
  });
1262
1355
  if (waitUntilReady)
1263
1356
  await waitForReady(this.conn, this.id);
@@ -1276,6 +1369,45 @@ class CloudTransport {
1276
1369
  timeoutMs: CLOUD_START_TIMEOUT_MS,
1277
1370
  });
1278
1371
  }
1372
+ async startJob(command, opts) {
1373
+ // Not idempotent: a resend could start the command twice.
1374
+ return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/jobs`, {
1375
+ json: {
1376
+ command,
1377
+ cwd: opts.workdir,
1378
+ env: opts.env ?? {},
1379
+ timeoutSeconds: opts.timeout,
1380
+ user: opts.user,
1381
+ },
1382
+ timeoutMs: CLOUD_START_TIMEOUT_MS,
1383
+ });
1384
+ }
1385
+ async listJobs() {
1386
+ const out = await cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/jobs`);
1387
+ return out.jobs;
1388
+ }
1389
+ async getJob(jobId) {
1390
+ return cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/jobs/${encodeURIComponent(jobId)}`);
1391
+ }
1392
+ async jobOutput(jobId, opts) {
1393
+ const q = new URLSearchParams({ stream: opts.stream ?? "stdout", offset: String(opts.offset ?? 0) });
1394
+ if (opts.limit !== undefined)
1395
+ q.set("limit", String(opts.limit));
1396
+ const out = await cloudFetch(this.conn, "GET", `/v1/machines/${this.id}/jobs/${encodeURIComponent(jobId)}/output?${q}`);
1397
+ const data = out.dataB64 !== undefined ? Buffer.from(out.dataB64, "base64") : Buffer.from(out.data);
1398
+ return {
1399
+ stream: out.stream,
1400
+ offset: out.offset,
1401
+ nextOffset: out.nextOffset,
1402
+ data,
1403
+ text: out.data,
1404
+ eof: out.eof,
1405
+ };
1406
+ }
1407
+ async cancelJob(jobId) {
1408
+ // Cancelling twice is harmless, so a 503 may be resent.
1409
+ return cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/jobs/${encodeURIComponent(jobId)}/cancel`, { idempotent: true });
1410
+ }
1279
1411
  async delete() {
1280
1412
  await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}`);
1281
1413
  }
package/dist/types.d.ts CHANGED
@@ -527,6 +527,12 @@ export interface ConnectOptions {
527
527
  /** Cloud only: read a binary response body, for a `fetch` whose responses
528
528
  * lack a working `arrayBuffer()`. */
529
529
  readResponseBytes?: (response: Response) => Promise<Uint8Array>;
530
+ /** Cloud only: how many times a request the server marks retryable is sent
531
+ * again, waiting as its `Retry-After` asks. A 429 is retried for any call;
532
+ * a 502/503/504 only for calls that are safe to repeat (reads, deletes,
533
+ * start/stop/resume), never for exec or create, which could run twice.
534
+ * Default 3; 0 turns retries off. */
535
+ maxRetries?: number;
530
536
  }
531
537
  /** Per-launch trusted host service. Keep the token out of logs and persistent config. */
532
538
  export interface EgressInterceptor {
@@ -596,3 +602,66 @@ export interface PublishedCacheDisk {
596
602
  cacheDisk: CacheDiskInfo;
597
603
  version: CacheDiskVersion;
598
604
  }
605
+ /** Options for {@link Machine.startJob}. Cloud only. */
606
+ export interface JobOptions {
607
+ /** Environment variables. */
608
+ env?: Record<string, string>;
609
+ /** Working directory inside the machine. */
610
+ workdir?: string;
611
+ /** Stop the job with SIGTERM after this many **seconds**; it then exits 143. */
612
+ timeout?: number;
613
+ /** Run the job as this user: a name from the image or a numeric `uid[:gid]`. */
614
+ user?: string;
615
+ }
616
+ /** `running`; `exited` (see `exitCode`); or `lost`, when the machine stopped or
617
+ * restarted while the job ran, so it never finished. */
618
+ export type JobState = "running" | "exited" | "lost";
619
+ /** A job and what is known about it. */
620
+ export interface JobInfo {
621
+ id: string;
622
+ machineId: string;
623
+ /** The command as it was run (argv). */
624
+ command: string[];
625
+ state: JobState;
626
+ /** Set once `exited`; 143 when the job was cancelled or hit its timeout. */
627
+ exitCode?: number;
628
+ createdAt: string;
629
+ finishedAt?: string;
630
+ /** Bytes of stdout written so far, when the machine was asked. */
631
+ stdoutBytes?: number;
632
+ /** Bytes of stderr written so far, when the machine was asked. */
633
+ stderrBytes?: number;
634
+ }
635
+ /** Which slice of a job's output to read. */
636
+ export interface JobOutputOptions {
637
+ /** Default `"stdout"`. */
638
+ stream?: "stdout" | "stderr";
639
+ /** Byte offset to start at. Default 0. */
640
+ offset?: number;
641
+ /** Most bytes to return. The server defaults to 256 KiB and caps at 1 MiB. */
642
+ limit?: number;
643
+ }
644
+ /** A slice of a job's stdout or stderr. */
645
+ export interface JobOutput {
646
+ stream: "stdout" | "stderr";
647
+ /** Byte offset this slice starts at. */
648
+ offset: number;
649
+ /** Byte offset to read from next. */
650
+ nextOffset: number;
651
+ /** The slice, byte-exact. */
652
+ data: Buffer;
653
+ /** The slice as lossy UTF-8. */
654
+ text: string;
655
+ /** True once the job has finished and this slice reaches the end of the
656
+ * stream: nothing more will be written. */
657
+ eof: boolean;
658
+ }
659
+ /** How {@link Job.wait} polls. */
660
+ export interface JobWaitOptions {
661
+ /** Delay between polls. Default 1000 ms. */
662
+ pollMs?: number;
663
+ /** Give up with a `TIMEOUT` error after this long. Default: wait forever. */
664
+ timeoutMs?: number;
665
+ /** Stop waiting; rejects with `signal.reason`. The job keeps running. */
666
+ signal?: AbortSignal;
667
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smolmachines",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "description": "Embed isolated microVM sandboxes directly in your code — no server to run.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "smol-machines",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "smolmachines-darwin-arm64": "1.27.0",
68
- "smolmachines-linux-x64-gnu": "1.27.0",
69
- "smolmachines-linux-arm64-gnu": "1.27.0"
67
+ "smolmachines-darwin-arm64": "1.27.2",
68
+ "smolmachines-linux-x64-gnu": "1.27.2",
69
+ "smolmachines-linux-arm64-gnu": "1.27.2"
70
70
  }
71
71
  }