smolmachines 1.26.1 → 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 +52 -7
- package/dist/assets.d.ts +15 -11
- package/dist/assets.js +18 -14
- package/dist/binding.d.ts +5 -0
- package/dist/errors.d.ts +22 -1
- package/dist/errors.js +5 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/machine.d.ts +48 -5
- package/dist/machine.js +112 -6
- package/dist/native.d.ts +1 -0
- package/dist/transport.d.ts +20 -1
- package/dist/transport.js +236 -38
- package/dist/types.d.ts +78 -10
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -87,10 +87,32 @@ try {
|
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Local machines also accept a saved Docker/OCI image archive or an unpacked rootfs
|
|
91
|
+
as `image`. The SDK stages an archive once and reuses its content hash; a
|
|
92
|
+
missing local path fails immediately with the path in the error instead of
|
|
93
|
+
trying to pull it from a registry. Build your image first, then save it:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
docker save my-agent:latest -o my-agent.tar
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const m = await Machine.create(
|
|
101
|
+
{ image: './my-agent.tar', network: false },
|
|
102
|
+
{ target: 'local' },
|
|
103
|
+
);
|
|
104
|
+
try {
|
|
105
|
+
console.log((await m.exec(['sh', '-c', 'pwd'])).stdout);
|
|
106
|
+
} finally {
|
|
107
|
+
await m.delete();
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
When a published service starts through `exec()` (local or cloud), set
|
|
112
|
+
`waitForPorts: false` on creation. The SDK waits for the guest agent so
|
|
113
|
+
`exec()` can start the listener; call `waitUntilReady()` once the service is
|
|
114
|
+
listening. The default waits for all published ports. Select
|
|
115
|
+
`{ target: 'cloud' }` to use the same flow on Smol Cloud.
|
|
94
116
|
|
|
95
117
|
```ts
|
|
96
118
|
const m = await Machine.create(
|
|
@@ -109,10 +131,13 @@ try {
|
|
|
109
131
|
}
|
|
110
132
|
```
|
|
111
133
|
|
|
112
|
-
When reconnecting to
|
|
134
|
+
When reconnecting to a running local machine whose published service has
|
|
113
135
|
stopped, `Machine.connect(name, { target: 'local', waitForPorts: false })`
|
|
114
|
-
attaches after the agent is ready,
|
|
115
|
-
|
|
136
|
+
attaches after the agent is ready, without waiting for the listener. Cloud
|
|
137
|
+
`connect()` normally attaches without waiting; with `waitForPorts: false`, it
|
|
138
|
+
waits for a successful guest-agent command without requiring the port to be
|
|
139
|
+
open. Connect without that flag to inspect or delete a stopped or paused cloud
|
|
140
|
+
machine.
|
|
116
141
|
|
|
117
142
|
To reach a service **inside** the VM, use the authenticated connect bridge —
|
|
118
143
|
**no Cloudflare/localhost.run tunnel, no public exposure, no egress allow-list.**
|
|
@@ -215,6 +240,9 @@ Existing network connections may need to reconnect.
|
|
|
215
240
|
- `machine.exec(command, opts?)` / `machine.run(image, command, opts?)` → `ExecResult`.
|
|
216
241
|
- `machine.execStream(command, opts?)` → `AsyncGenerator<ExecEvent>`.
|
|
217
242
|
- `machine.readFile(path)` / `machine.writeFile(path, data, mode?)`.
|
|
243
|
+
- `machine.readFileStream(path)` yields `Uint8Array` chunks without buffering
|
|
244
|
+
the full Cloud download; stopping iteration cancels the response. Local
|
|
245
|
+
machines currently yield one buffered chunk.
|
|
218
246
|
- `machine.pullImage(image)` / `machine.listImages()`.
|
|
219
247
|
- `machine.branch(name, options?)` / `machine.branchBatch(options)`.
|
|
220
248
|
- `machine.checkpoint(output, { store? })`, `Machine.restoreCheckpoint(...)`,
|
|
@@ -332,6 +360,23 @@ const { id } = await base.checkpoint();
|
|
|
332
360
|
const a = await Machine.restoreCheckpoint(id, "project-a", undefined, { cacheDisk: { cache: "project-a" } });
|
|
333
361
|
```
|
|
334
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
|
+
|
|
335
380
|
## Building from source
|
|
336
381
|
|
|
337
382
|
This package's native core lives alongside it (Rust, `src/*.rs`) and links the
|
package/dist/assets.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/** Auto-wiring for bundled native assets.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
3
|
+
* Resolves the package's bundled, signed boot helper, hypervisor libraries and
|
|
4
|
+
* guest rootfs so the SDK works on a plain `node` with no manual env setup:
|
|
5
|
+
* - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
|
|
6
|
+
* codesigned with `com.apple.security.hypervisor`, so the user's `node`
|
|
7
|
+
* needs no entitlement).
|
|
8
|
+
* - libDir → the dir holding libkrun/libkrunfw.
|
|
9
|
+
* - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
|
|
10
|
+
* on first use), so a plain `npm i` is fully self-contained.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The resolved paths are handed to the engine in-process by
|
|
13
|
+
* `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
|
|
14
|
+
* CLI this program spawns must not inherit this package's helper (it exits
|
|
15
|
+
* when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
|
|
16
|
+
* SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
|
|
17
|
+
* `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
|
|
18
|
+
* import.
|
|
15
19
|
*/
|
|
16
20
|
/** The per-platform npm package that carries the native addon, boot helper,
|
|
17
21
|
* hypervisor libraries and guest rootfs for each supported
|
package/dist/assets.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/** Auto-wiring for bundled native assets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
4
|
+
* Resolves the package's bundled, signed boot helper, hypervisor libraries and
|
|
5
|
+
* guest rootfs so the SDK works on a plain `node` with no manual env setup:
|
|
6
|
+
* - bootBinary → bundled `smol-vmm` helper (handles `_boot-vm`; on macOS
|
|
7
|
+
* codesigned with `com.apple.security.hypervisor`, so the user's `node`
|
|
8
|
+
* needs no entitlement).
|
|
9
|
+
* - libDir → the dir holding libkrun/libkrunfw.
|
|
10
|
+
* - agentRootfsTar → bundled guest rootfs tarball (the engine extracts it
|
|
11
|
+
* on first use), so a plain `npm i` is fully self-contained.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The resolved paths are handed to the engine in-process by
|
|
14
|
+
* `configureRuntimeAssets` and are never written to `process.env`: a `smolvm`
|
|
15
|
+
* CLI this program spawns must not inherit this package's helper (it exits
|
|
16
|
+
* when its parent does) or its libraries. A user-provided SMOLVM_BOOT_BINARY,
|
|
17
|
+
* SMOLVM_LIB_DIR or SMOLVM_AGENT_ROOTFS(_TAR) always wins. Called by
|
|
18
|
+
* `native.ts` right before the addon loads — i.e. on first LOCAL use, never at
|
|
19
|
+
* import.
|
|
16
20
|
*/
|
|
17
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
22
|
exports.PLATFORM_PACKAGES = void 0;
|
|
@@ -77,18 +81,18 @@ function wireBundledAssets() {
|
|
|
77
81
|
if (!(0, node_fs_1.existsSync)(nativeDir))
|
|
78
82
|
continue;
|
|
79
83
|
const helper = (0, node_path_1.join)(nativeDir, helperName);
|
|
84
|
+
// Resolved paths are handed to the engine by `configureRuntimeAssets`, never
|
|
85
|
+
// written to `process.env`: a `smolvm` CLI this program spawns must not
|
|
86
|
+
// inherit this package's helper, libraries or rootfs.
|
|
80
87
|
if (!assets.bootBinary && (0, node_fs_1.existsSync)(helper)) {
|
|
81
88
|
assets.bootBinary = helper;
|
|
82
|
-
process.env.SMOLVM_BOOT_BINARY = helper;
|
|
83
89
|
}
|
|
84
90
|
if (!assets.libDir) {
|
|
85
91
|
assets.libDir = nativeDir;
|
|
86
|
-
process.env.SMOLVM_LIB_DIR = nativeDir;
|
|
87
92
|
}
|
|
88
93
|
const rootfsTar = (0, node_path_1.join)(nativeDir, 'agent-rootfs.tar');
|
|
89
94
|
if (!assets.agentRootfs && !assets.agentRootfsTar && (0, node_fs_1.existsSync)(rootfsTar)) {
|
|
90
95
|
assets.agentRootfsTar = rootfsTar;
|
|
91
|
-
process.env.SMOLVM_AGENT_ROOTFS_TAR = rootfsTar;
|
|
92
96
|
}
|
|
93
97
|
break;
|
|
94
98
|
}
|
package/dist/binding.d.ts
CHANGED
|
@@ -287,6 +287,11 @@ export declare class NapiMachine {
|
|
|
287
287
|
* for a failed boot. Never throws.
|
|
288
288
|
*/
|
|
289
289
|
static checkHost(): HostAvailability
|
|
290
|
+
/**
|
|
291
|
+
* Stage a local archive or rootfs directory before creating the machine.
|
|
292
|
+
* Large archives are hashed off the JavaScript event loop.
|
|
293
|
+
*/
|
|
294
|
+
static prepareImage(image: string): Promise<string>
|
|
290
295
|
/** Create a new machine. Does not start the VM yet — call `start()`. */
|
|
291
296
|
constructor(config: MachineConfig)
|
|
292
297
|
/**
|
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
|
-
|
|
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,14 +9,14 @@
|
|
|
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();
|
|
16
16
|
/**
|
|
17
17
|
* Create and start a machine. By default this waits for the guest agent and
|
|
18
|
-
* all published ports.
|
|
19
|
-
*
|
|
18
|
+
* all published ports. `waitForPorts: false` returns when the agent is
|
|
19
|
+
* ready so `exec()` can start a service on a published port.
|
|
20
20
|
* A cloud state of `"started"` alone means only that the VM process launched.
|
|
21
21
|
*
|
|
22
22
|
* @param config machine configuration (a name is generated if omitted; `image`
|
|
@@ -29,11 +29,14 @@ export declare class Machine {
|
|
|
29
29
|
* machine made elsewhere (another process, the console, the REST API).
|
|
30
30
|
* - local (default): re-opens a persisted machine by NAME, starting it if
|
|
31
31
|
* stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
|
|
32
|
+
* A paused machine stays paused; call `resume()` to restore its saved
|
|
33
|
+
* execution before running commands.
|
|
32
34
|
* Set `conn.waitForPorts: false` to attach after agent readiness when a
|
|
33
35
|
* published service is not listening (for example, to delete it).
|
|
34
36
|
* - cloud: looks up the machine by id; throws if it doesn't exist.
|
|
35
|
-
* This does not wait for readiness
|
|
36
|
-
*
|
|
37
|
+
* This does not wait for readiness by default. With `waitForPorts: false`,
|
|
38
|
+
* it waits for the guest agent so `exec()` can start a published service.
|
|
39
|
+
* Call `waitUntilReady()` before expecting the published service to respond.
|
|
37
40
|
*
|
|
38
41
|
* @param id local machine name, or cloud machine id (`mach-…`)
|
|
39
42
|
* @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
|
|
@@ -98,6 +101,21 @@ export declare class Machine {
|
|
|
98
101
|
* namespace, re-deployable anywhere. Distinct from a portable checkpoint,
|
|
99
102
|
* which also keeps live memory. Cloud only. */
|
|
100
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;
|
|
101
119
|
/** Whether the machine is READY to do work. `state()` becoming "started"
|
|
102
120
|
* means only that the VM process launched — the guest is still booting and
|
|
103
121
|
* is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
|
|
@@ -136,6 +154,10 @@ export declare class Machine {
|
|
|
136
154
|
execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
|
|
137
155
|
/** Read a file from the machine. */
|
|
138
156
|
readFile(path: string): Promise<Buffer>;
|
|
157
|
+
/** Read a file in chunks without buffering the entire Cloud response.
|
|
158
|
+
* Local machines currently yield one buffered chunk from the native binding.
|
|
159
|
+
* Stopping iteration early cancels the Cloud HTTP download. */
|
|
160
|
+
readFileStream(path: string): AsyncGenerator<Uint8Array>;
|
|
139
161
|
/** Write a file into the machine. */
|
|
140
162
|
writeFile(path: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
141
163
|
/** Pull an OCI image into the machine's storage. (local) */
|
|
@@ -251,6 +273,27 @@ export declare class Machine {
|
|
|
251
273
|
* @returns the grader's `ExecResult` (use `.success` for pass/fail, `.stdout` for a score) */
|
|
252
274
|
rewardFork(command: string[], opts?: ExecOptions, name?: string): Promise<ExecResult>;
|
|
253
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
|
+
}
|
|
254
297
|
/** A leased RL episode: a freshly-provisioned clone plus its lease. Created by
|
|
255
298
|
* `Machine.assign()`. Always `complete()` it (e.g. in a `finally`) so the clone
|
|
256
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");
|
|
@@ -57,8 +57,8 @@ class Machine {
|
|
|
57
57
|
}
|
|
58
58
|
/**
|
|
59
59
|
* Create and start a machine. By default this waits for the guest agent and
|
|
60
|
-
* all published ports.
|
|
61
|
-
*
|
|
60
|
+
* all published ports. `waitForPorts: false` returns when the agent is
|
|
61
|
+
* ready so `exec()` can start a service on a published port.
|
|
62
62
|
* A cloud state of `"started"` alone means only that the VM process launched.
|
|
63
63
|
*
|
|
64
64
|
* @param config machine configuration (a name is generated if omitted; `image`
|
|
@@ -78,11 +78,14 @@ class Machine {
|
|
|
78
78
|
* machine made elsewhere (another process, the console, the REST API).
|
|
79
79
|
* - local (default): re-opens a persisted machine by NAME, starting it if
|
|
80
80
|
* stopped — pairs with `Machine.create({ name, … }, …)` + `persistent`.
|
|
81
|
+
* A paused machine stays paused; call `resume()` to restore its saved
|
|
82
|
+
* execution before running commands.
|
|
81
83
|
* Set `conn.waitForPorts: false` to attach after agent readiness when a
|
|
82
84
|
* published service is not listening (for example, to delete it).
|
|
83
85
|
* - cloud: looks up the machine by id; throws if it doesn't exist.
|
|
84
|
-
* This does not wait for readiness
|
|
85
|
-
*
|
|
86
|
+
* This does not wait for readiness by default. With `waitForPorts: false`,
|
|
87
|
+
* it waits for the guest agent so `exec()` can start a published service.
|
|
88
|
+
* Call `waitUntilReady()` before expecting the published service to respond.
|
|
86
89
|
*
|
|
87
90
|
* @param id local machine name, or cloud machine id (`mach-…`)
|
|
88
91
|
* @param conn backend selection (local by default; cloud via `{ target: 'cloud', apiKey }` or `SMOL_CLOUD_TOKEN`)
|
|
@@ -197,6 +200,34 @@ class Machine {
|
|
|
197
200
|
throw new errors_1.NotSupportedError("exportArtifact is cloud-only.");
|
|
198
201
|
return this.transport.exportArtifact();
|
|
199
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
|
+
}
|
|
200
231
|
/** Whether the machine is READY to do work. `state()` becoming "started"
|
|
201
232
|
* means only that the VM process launched — the guest is still booting and
|
|
202
233
|
* is NOT yet usable. `ready` becomes true once the in-VM agent is reachable
|
|
@@ -262,6 +293,12 @@ class Machine {
|
|
|
262
293
|
readFile(path) {
|
|
263
294
|
return this.transport.readFile(path);
|
|
264
295
|
}
|
|
296
|
+
/** Read a file in chunks without buffering the entire Cloud response.
|
|
297
|
+
* Local machines currently yield one buffered chunk from the native binding.
|
|
298
|
+
* Stopping iteration early cancels the Cloud HTTP download. */
|
|
299
|
+
readFileStream(path) {
|
|
300
|
+
return this.transport.readFileStream(path);
|
|
301
|
+
}
|
|
265
302
|
/** Write a file into the machine. */
|
|
266
303
|
writeFile(path, data, mode) {
|
|
267
304
|
const buf = typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
|
@@ -429,6 +466,75 @@ class Machine {
|
|
|
429
466
|
}
|
|
430
467
|
}
|
|
431
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
|
+
}
|
|
432
538
|
/** A leased RL episode: a freshly-provisioned clone plus its lease. Created by
|
|
433
539
|
* `Machine.assign()`. Always `complete()` it (e.g. in a `finally`) so the clone
|
|
434
540
|
* is reclaimed — the RL "one clean worker per task, guaranteed reclaimed"
|
package/dist/native.d.ts
CHANGED
|
@@ -150,6 +150,7 @@ export interface NapiMachine {
|
|
|
150
150
|
}
|
|
151
151
|
export interface NapiMachineCtor {
|
|
152
152
|
new (config: NativeMachineConfig): NapiMachine;
|
|
153
|
+
prepareImage(image: string): Promise<string>;
|
|
153
154
|
connect(name: string, interceptorAddress?: string, interceptorToken?: string): NapiMachine;
|
|
154
155
|
list(): Promise<NativeMachineSummary[]>;
|
|
155
156
|
restoreCheckpoint(name: string, artifact: string, keepIdentity?: boolean): NapiMachine;
|
package/dist/transport.d.ts
CHANGED
|
@@ -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>;
|
|
@@ -41,6 +51,7 @@ export interface Transport {
|
|
|
41
51
|
run(image: string, command: string[], opts?: ExecOptions): Promise<RawExec>;
|
|
42
52
|
execStream(command: string[], opts?: ExecOptions): AsyncGenerator<ExecEvent>;
|
|
43
53
|
readFile(path: string): Promise<Buffer>;
|
|
54
|
+
readFileStream(path: string): AsyncGenerator<Uint8Array>;
|
|
44
55
|
writeFile(path: string, data: Buffer, mode?: number): Promise<void>;
|
|
45
56
|
pullImage(image: string): Promise<ImageInfo>;
|
|
46
57
|
listImages(): Promise<ImageInfo[]>;
|
|
@@ -121,6 +132,8 @@ interface CloudConn {
|
|
|
121
132
|
/** The caller's `fetch` (ConnectOptions.fetch), when it supplied one. */
|
|
122
133
|
fetch?: typeof fetch;
|
|
123
134
|
readResponseBytes?: (response: Response) => Promise<Uint8Array>;
|
|
135
|
+
/** ConnectOptions.maxRetries, when the caller set it. */
|
|
136
|
+
maxRetries?: number;
|
|
124
137
|
}
|
|
125
138
|
/** The largest file the cloud's file routes accept. */
|
|
126
139
|
export declare const CLOUD_MAX_FILE_BYTES: number;
|
|
@@ -139,6 +152,12 @@ export declare function errorSentence(body: string): string;
|
|
|
139
152
|
* files route is a wildcard (`/files/<path>`), so slashes are meaningful while
|
|
140
153
|
* spaces/?/#/% in a filename must be escaped. */
|
|
141
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;
|
|
142
161
|
/** Does this request target the cloud?
|
|
143
162
|
*
|
|
144
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;
|
|
@@ -471,6 +473,11 @@ class LocalTransport {
|
|
|
471
473
|
throw (0, errors_1.wrapNativeError)(e);
|
|
472
474
|
}
|
|
473
475
|
}
|
|
476
|
+
// The native local binding currently returns a Buffer; keep the same API
|
|
477
|
+
// across targets until native streaming is available.
|
|
478
|
+
async *readFileStream(path) {
|
|
479
|
+
yield await this.readFile(path);
|
|
480
|
+
}
|
|
474
481
|
async writeFile(path, data, mode) {
|
|
475
482
|
try {
|
|
476
483
|
await this.inner.writeFile(path, data, mode === undefined ? undefined : { mode });
|
|
@@ -697,6 +704,7 @@ function cloudConnOf(conn, baseUrl, apiKey) {
|
|
|
697
704
|
apiKey,
|
|
698
705
|
...(conn.fetch ? { fetch: conn.fetch } : {}),
|
|
699
706
|
...(conn.readResponseBytes ? { readResponseBytes: conn.readResponseBytes } : {}),
|
|
707
|
+
...(conn.maxRetries !== undefined ? { maxRetries: conn.maxRetries } : {}),
|
|
700
708
|
};
|
|
701
709
|
}
|
|
702
710
|
const DEFAULT_CLOUD_URL = "https://api.smolmachines.com";
|
|
@@ -760,7 +768,110 @@ function errorSentence(body) {
|
|
|
760
768
|
function encodePath(p) {
|
|
761
769
|
return p.split("/").map(encodeURIComponent).join("/");
|
|
762
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
|
+
}
|
|
763
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) {
|
|
764
875
|
const headers = {
|
|
765
876
|
authorization: `Bearer ${conn.apiKey}`,
|
|
766
877
|
};
|
|
@@ -803,18 +914,10 @@ async function cloudFetch(conn, method, path, opts = {}) {
|
|
|
803
914
|
clearTimeout(timer);
|
|
804
915
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
805
916
|
}
|
|
806
|
-
if (!res.ok)
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
// without this the id is invisible and support can't correlate the call.
|
|
811
|
-
const rid = res.headers.get("x-request-id");
|
|
812
|
-
throw new errors_1.SmolError(res.status === 404
|
|
813
|
-
? "NOT_FOUND"
|
|
814
|
-
: res.status === 401
|
|
815
|
-
? "UNAUTHORIZED"
|
|
816
|
-
: "SMOLVM_ERROR", `cloud ${method} ${path} → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
|
|
817
|
-
}
|
|
917
|
+
if (!res.ok)
|
|
918
|
+
throw await cloudError(res, `cloud ${method} ${path}`);
|
|
919
|
+
if (opts.accept === "response")
|
|
920
|
+
return res;
|
|
818
921
|
if (opts.accept === "text")
|
|
819
922
|
return (await res.text());
|
|
820
923
|
if (opts.accept === "bytes") {
|
|
@@ -836,11 +939,11 @@ async function cloudFetch(conn, method, path, opts = {}) {
|
|
|
836
939
|
* caller keeps polling until this passes or the deadline hits. */
|
|
837
940
|
async function agentReachable(conn, id) {
|
|
838
941
|
try {
|
|
839
|
-
await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
|
|
942
|
+
const result = await cloudFetch(conn, "POST", `/v1/machines/${id}/exec`, {
|
|
840
943
|
json: { command: ["sh", "-c", "true"] },
|
|
841
944
|
timeoutMs: 15_000,
|
|
842
945
|
});
|
|
843
|
-
return
|
|
946
|
+
return result?.exitCode === 0;
|
|
844
947
|
}
|
|
845
948
|
catch {
|
|
846
949
|
return false;
|
|
@@ -855,8 +958,10 @@ async function agentReachable(conn, id) {
|
|
|
855
958
|
* `started` is NOT yet usable: the guest is still booting, and acting then is
|
|
856
959
|
* the classic teardown race (works on a slow cold start, times out on a warm
|
|
857
960
|
* one). Older control planes omit `ready`; there we fall back to the coarse
|
|
858
|
-
* `started`/`running` state so this never hangs against them.
|
|
859
|
-
|
|
961
|
+
* `started`/`running` state so this never hangs against them.
|
|
962
|
+
* With `waitForPorts: false`, probe the agent even if ports are published:
|
|
963
|
+
* the caller must be able to exec the service before any port can open. */
|
|
964
|
+
async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000, waitForPorts = true) {
|
|
860
965
|
const start = Date.now();
|
|
861
966
|
const deadline = start + timeoutMs;
|
|
862
967
|
for (;;) {
|
|
@@ -882,8 +987,15 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
|
|
|
882
987
|
if (state === "stopped" || state === "deleted") {
|
|
883
988
|
throw new errors_1.SmolError("SMOLVM_ERROR", `machine ${id} entered ${state} before becoming ready`);
|
|
884
989
|
}
|
|
990
|
+
// With deferred ports, even `started` and legacy servers' absent `ready`
|
|
991
|
+
// are insufficient: only a successful in-guest exec proves agent readiness.
|
|
992
|
+
if (!waitForPorts && (state === "started" || state === "running")) {
|
|
993
|
+
if (await agentReachable(conn, id))
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
885
996
|
// Back-compat: `ready` absent entirely → old server, gate on state.
|
|
886
|
-
if (
|
|
997
|
+
if (waitForPorts &&
|
|
998
|
+
m &&
|
|
887
999
|
m.ready === undefined &&
|
|
888
1000
|
(state === "started" || state === "running"))
|
|
889
1001
|
return;
|
|
@@ -893,7 +1005,8 @@ async function waitForReady(conn, id, timeoutMs = 120_000, intervalMs = 1_000) {
|
|
|
893
1005
|
// into). After a grace (so `ready` can flip first and this never preempts a
|
|
894
1006
|
// machine legitimately about to become ready), confirm the guest agent is
|
|
895
1007
|
// reachable directly (a trivial exec) and treat that as ready.
|
|
896
|
-
if (
|
|
1008
|
+
if (waitForPorts &&
|
|
1009
|
+
(state === "started" || state === "running") &&
|
|
897
1010
|
!m?.ports?.length &&
|
|
898
1011
|
Date.now() - start >= NO_PORT_READY_PROBE_GRACE_MS &&
|
|
899
1012
|
(await agentReachable(conn, id)))
|
|
@@ -1063,15 +1176,8 @@ class CloudTransport {
|
|
|
1063
1176
|
throw signal.reason;
|
|
1064
1177
|
throw new errors_1.SmolError("CONNECTION", `cloud exec/stream failed: ${e.message}`);
|
|
1065
1178
|
}
|
|
1066
|
-
if (!res.ok)
|
|
1067
|
-
|
|
1068
|
-
const rid = res.headers.get("x-request-id");
|
|
1069
|
-
throw new errors_1.SmolError(res.status === 404
|
|
1070
|
-
? "NOT_FOUND"
|
|
1071
|
-
: res.status === 401
|
|
1072
|
-
? "UNAUTHORIZED"
|
|
1073
|
-
: "SMOLVM_ERROR", `cloud POST /v1/machines/${this.id}/exec/stream → ${res.status}${text ? `: ${text}` : ""}${rid ? ` [request id: ${rid}]` : ""}`);
|
|
1074
|
-
}
|
|
1179
|
+
if (!res.ok)
|
|
1180
|
+
throw await cloudError(res, `cloud POST /v1/machines/${this.id}/exec/stream`);
|
|
1075
1181
|
if (!res.body)
|
|
1076
1182
|
throw new errors_1.SmolError("SMOLVM_ERROR", "exec/stream returned no response body");
|
|
1077
1183
|
// Parse the server's SSE stream: each event is `event: <kind>` + one or more
|
|
@@ -1155,6 +1261,51 @@ class CloudTransport {
|
|
|
1155
1261
|
timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
|
|
1156
1262
|
});
|
|
1157
1263
|
}
|
|
1264
|
+
async *readFileStream(path) {
|
|
1265
|
+
const route = `/v1/machines/${this.id}/files/${encodePath(path)}`;
|
|
1266
|
+
const res = await cloudFetch(this.conn, "GET", route, {
|
|
1267
|
+
accept: "response",
|
|
1268
|
+
timeoutMs: transferTimeoutMs(exports.CLOUD_MAX_FILE_BYTES),
|
|
1269
|
+
});
|
|
1270
|
+
// A caller-supplied byte reader may transform the response. Honor it just
|
|
1271
|
+
// as readFile does, even when the injected fetch exposes a body stream.
|
|
1272
|
+
if (this.conn.readResponseBytes) {
|
|
1273
|
+
const bytes = await this.conn.readResponseBytes(res);
|
|
1274
|
+
if (bytes.length > 0)
|
|
1275
|
+
yield bytes;
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (!res.body)
|
|
1279
|
+
return;
|
|
1280
|
+
const reader = res.body.getReader();
|
|
1281
|
+
try {
|
|
1282
|
+
for (;;) {
|
|
1283
|
+
let timer;
|
|
1284
|
+
let chunk;
|
|
1285
|
+
try {
|
|
1286
|
+
chunk = await Promise.race([
|
|
1287
|
+
reader.read(),
|
|
1288
|
+
new Promise((_, reject) => {
|
|
1289
|
+
timer = setTimeout(() => reject(new errors_1.SmolError("TIMEOUT", `cloud GET ${route} stalled for ${CLOUD_TIMEOUT_MS}ms`)), CLOUD_TIMEOUT_MS);
|
|
1290
|
+
}),
|
|
1291
|
+
]);
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
if (timer !== undefined)
|
|
1295
|
+
clearTimeout(timer);
|
|
1296
|
+
}
|
|
1297
|
+
if (chunk.done)
|
|
1298
|
+
return;
|
|
1299
|
+
if (chunk.value.length > 0)
|
|
1300
|
+
yield chunk.value;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
finally {
|
|
1304
|
+
// `break`, a thrown error, or a stalled read must close the HTTP body.
|
|
1305
|
+
await reader.cancel().catch(() => { });
|
|
1306
|
+
reader.releaseLock();
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1158
1309
|
async writeFile(path, data, mode) {
|
|
1159
1310
|
checkCloudFileSize(path, data.length);
|
|
1160
1311
|
await cloudFetch(this.conn, "PUT", `/v1/machines/${this.id}/files/${encodePath(path)}`, {
|
|
@@ -1180,13 +1331,16 @@ class CloudTransport {
|
|
|
1180
1331
|
throw new errors_1.NotSupportedError("setNetworkPolicy() is local-only.");
|
|
1181
1332
|
}
|
|
1182
1333
|
async stop() {
|
|
1183
|
-
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/stop
|
|
1334
|
+
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/stop`, { idempotent: true });
|
|
1184
1335
|
}
|
|
1185
1336
|
async pause() {
|
|
1186
1337
|
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/pause`, { timeoutMs: CLOUD_START_TIMEOUT_MS });
|
|
1187
1338
|
}
|
|
1188
1339
|
async resume(waitUntilReady = true) {
|
|
1189
|
-
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, {
|
|
1340
|
+
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/resume`, {
|
|
1341
|
+
timeoutMs: CLOUD_START_TIMEOUT_MS,
|
|
1342
|
+
idempotent: true,
|
|
1343
|
+
});
|
|
1190
1344
|
if (waitUntilReady)
|
|
1191
1345
|
await waitForReady(this.conn, this.id);
|
|
1192
1346
|
}
|
|
@@ -1196,6 +1350,7 @@ class CloudTransport {
|
|
|
1196
1350
|
// Resume a stopped machine, then wait for its agent so the handle is usable.
|
|
1197
1351
|
await cloudFetch(this.conn, "POST", `/v1/machines/${this.id}/start`, {
|
|
1198
1352
|
timeoutMs: CLOUD_START_TIMEOUT_MS,
|
|
1353
|
+
idempotent: true,
|
|
1199
1354
|
});
|
|
1200
1355
|
if (waitUntilReady)
|
|
1201
1356
|
await waitForReady(this.conn, this.id);
|
|
@@ -1214,6 +1369,45 @@ class CloudTransport {
|
|
|
1214
1369
|
timeoutMs: CLOUD_START_TIMEOUT_MS,
|
|
1215
1370
|
});
|
|
1216
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
|
+
}
|
|
1217
1411
|
async delete() {
|
|
1218
1412
|
await cloudFetch(this.conn, "DELETE", `/v1/machines/${this.id}`);
|
|
1219
1413
|
}
|
|
@@ -1481,8 +1675,6 @@ async function makeTransport(config, conn) {
|
|
|
1481
1675
|
// run the workload as the image's default user.
|
|
1482
1676
|
if (config.user !== undefined)
|
|
1483
1677
|
throw new errors_1.NotSupportedError("user is local-only.");
|
|
1484
|
-
if (config.waitForPorts === false || conn.waitForPorts === false)
|
|
1485
|
-
throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
|
|
1486
1678
|
// Cloud is settled: NOW the CLI's stored login may supply the credential
|
|
1487
1679
|
// and endpoint, which is the reuse `smol auth login` promises.
|
|
1488
1680
|
const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
|
|
@@ -1586,7 +1778,7 @@ async function makeTransport(config, conn) {
|
|
|
1586
1778
|
startError = e instanceof Error ? e.message : String(e);
|
|
1587
1779
|
}
|
|
1588
1780
|
try {
|
|
1589
|
-
await waitForReady(cloudConn, id);
|
|
1781
|
+
await waitForReady(cloudConn, id, 120_000, 1_000, (config.waitForPorts ?? conn.waitForPorts) !== false);
|
|
1590
1782
|
}
|
|
1591
1783
|
catch (e) {
|
|
1592
1784
|
if (startError !== undefined && e instanceof errors_1.SmolError)
|
|
@@ -1612,6 +1804,9 @@ async function makeTransport(config, conn) {
|
|
|
1612
1804
|
// Validate the config before loading the engine, so a config the local
|
|
1613
1805
|
// target cannot honour fails the same way whether or not it is installed.
|
|
1614
1806
|
const nativeConfig = toNativeConfig(name, config);
|
|
1807
|
+
if (nativeConfig.image) {
|
|
1808
|
+
nativeConfig.image = await (0, native_1.getNapiMachine)().prepareImage(nativeConfig.image);
|
|
1809
|
+
}
|
|
1615
1810
|
const inner = new ((0, native_1.getNapiMachine)())(nativeConfig);
|
|
1616
1811
|
// A detached machine is nobody's to stop on a signal — it was created to
|
|
1617
1812
|
// survive this process — and its branches inherit that.
|
|
@@ -1738,9 +1933,10 @@ async function connectTransport(id, conn) {
|
|
|
1738
1933
|
const transport = new LocalTransport(conn.egressInterceptor
|
|
1739
1934
|
? (0, native_1.getNapiMachine)().connect(id, conn.egressInterceptor.address, conn.egressInterceptor.token)
|
|
1740
1935
|
: (0, native_1.getNapiMachine)().connect(id), false, conn.egressInterceptor, conn.handleSignals ?? true);
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
|
|
1936
|
+
// Frozen and durably paused machines are intentionally not agent-ready.
|
|
1937
|
+
// A paused machine must be resumed explicitly before it can serve work.
|
|
1938
|
+
const state = await transport.state();
|
|
1939
|
+
if (state !== "frozen" && state !== "paused" && state !== "pausing" && conn.waitForPorts !== false) {
|
|
1744
1940
|
await transport.waitUntilReady();
|
|
1745
1941
|
}
|
|
1746
1942
|
return transport;
|
|
@@ -1751,8 +1947,6 @@ async function connectTransport(id, conn) {
|
|
|
1751
1947
|
}
|
|
1752
1948
|
if (conn.egressInterceptor)
|
|
1753
1949
|
throw new errors_1.NotSupportedError("egressInterceptor is local-only.");
|
|
1754
|
-
if (conn.waitForPorts === false)
|
|
1755
|
-
throw new errors_1.NotSupportedError("waitForPorts: false is local-only.");
|
|
1756
1950
|
// As in makeTransport: the CLI-login fallback applies only once the cloud
|
|
1757
1951
|
// target is already selected.
|
|
1758
1952
|
const { apiKey: cliKey, endpoint: cliUrl } = cliSession(conn.target);
|
|
@@ -1783,7 +1977,11 @@ async function connectTransport(id, conn) {
|
|
|
1783
1977
|
throw e;
|
|
1784
1978
|
m = hit;
|
|
1785
1979
|
}
|
|
1786
|
-
|
|
1980
|
+
const resolvedId = m.id ?? id;
|
|
1981
|
+
if (conn.waitForPorts === false && m.state !== "frozen") {
|
|
1982
|
+
await waitForReady(cloudConn, resolvedId, 120_000, 1_000, false);
|
|
1983
|
+
}
|
|
1984
|
+
return new CloudTransport(cloudConn, m.name ?? id, resolvedId);
|
|
1787
1985
|
}
|
|
1788
1986
|
/** Restore a durable cloud checkpoint into a new machine, then return only once
|
|
1789
1987
|
* the restored guest is ready. */
|
package/dist/types.d.ts
CHANGED
|
@@ -207,11 +207,11 @@ export interface MachineConfig {
|
|
|
207
207
|
* allocates the host port; readiness includes that published port accepting
|
|
208
208
|
* connections. */
|
|
209
209
|
ports?: PortSpec[];
|
|
210
|
-
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* Default true
|
|
210
|
+
/** Return from `Machine.create()` after the guest agent is ready, even if a
|
|
211
|
+
* published port is not accepting connections yet. Use this when `exec()`
|
|
212
|
+
* starts the service: the default port wait would deadlock before `exec()`
|
|
213
|
+
* can run. Call `machine.waitUntilReady()` after starting it.
|
|
214
|
+
* Default true. On cloud, false probes in-guest execution before returning. */
|
|
215
215
|
waitForPorts?: boolean;
|
|
216
216
|
/** Resource allocation. */
|
|
217
217
|
resources?: ResourceSpec;
|
|
@@ -497,11 +497,10 @@ export interface PortableCheckpointInfo {
|
|
|
497
497
|
}
|
|
498
498
|
/** Selects and configures the backend. Local (embedded) is the default. */
|
|
499
499
|
export interface ConnectOptions {
|
|
500
|
-
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
* on `MachineConfig` instead. */
|
|
500
|
+
/** On local machines, allow `Machine.connect()` to return without waiting
|
|
501
|
+
* for published ports. On cloud, false waits for successful in-guest exec
|
|
502
|
+
* without requiring a published listener; the default connection only
|
|
503
|
+
* attaches to the existing machine. For `create`, set this on MachineConfig. */
|
|
505
504
|
waitForPorts?: boolean;
|
|
506
505
|
/** Binding for a stopped local machine that requires intercepted egress. */
|
|
507
506
|
egressInterceptor?: EgressInterceptor;
|
|
@@ -528,6 +527,12 @@ export interface ConnectOptions {
|
|
|
528
527
|
/** Cloud only: read a binary response body, for a `fetch` whose responses
|
|
529
528
|
* lack a working `arrayBuffer()`. */
|
|
530
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;
|
|
531
536
|
}
|
|
532
537
|
/** Per-launch trusted host service. Keep the token out of logs and persistent config. */
|
|
533
538
|
export interface EgressInterceptor {
|
|
@@ -597,3 +602,66 @@ export interface PublishedCacheDisk {
|
|
|
597
602
|
cacheDisk: CacheDiskInfo;
|
|
598
603
|
version: CacheDiskVersion;
|
|
599
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.
|
|
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",
|
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
"test:e2e": "napi build --platform --js binding.js --dts binding.d.ts && tsx test/e2e.ts",
|
|
52
52
|
"test:staged": "tsx test/staged-mount-e2e.ts",
|
|
53
53
|
"test:agent-ready": "tsx test/local-agent-ready.ts",
|
|
54
|
+
"test:paused-connect": "tsx test/local-paused-connect.ts",
|
|
54
55
|
"gen:openapi": "bash scripts/gen-openapi.sh",
|
|
55
|
-
"test:cloud": "tsx test/cloud-mock.ts",
|
|
56
|
+
"test:cloud": "tsx test/cloud-mock.ts && tsx test/cloud-deferred-ports.ts",
|
|
56
57
|
"test:unit": "tsx test/unit.ts"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
@@ -63,8 +64,8 @@
|
|
|
63
64
|
"typescript": "^5.6.0"
|
|
64
65
|
},
|
|
65
66
|
"optionalDependencies": {
|
|
66
|
-
"smolmachines-darwin-arm64": "1.
|
|
67
|
-
"smolmachines-linux-x64-gnu": "1.
|
|
68
|
-
"smolmachines-linux-arm64-gnu": "1.
|
|
67
|
+
"smolmachines-darwin-arm64": "1.27.2",
|
|
68
|
+
"smolmachines-linux-x64-gnu": "1.27.2",
|
|
69
|
+
"smolmachines-linux-arm64-gnu": "1.27.2"
|
|
69
70
|
}
|
|
70
71
|
}
|