@sandboxrouter/sdk 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/bench-store.d.ts +12 -0
- package/dist/bench-store.js +23 -0
- package/dist/bench.d.ts +24 -0
- package/dist/bench.js +65 -0
- package/dist/catalog.d.ts +35 -0
- package/dist/catalog.js +198 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +195 -0
- package/dist/computers.d.ts +124 -0
- package/dist/computers.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/leaderboard.d.ts +39 -0
- package/dist/leaderboard.js +184 -0
- package/dist/providers/blaxel.d.ts +21 -0
- package/dist/providers/blaxel.js +152 -0
- package/dist/providers/daytona.d.ts +13 -0
- package/dist/providers/daytona.js +121 -0
- package/dist/providers/e2b.d.ts +10 -0
- package/dist/providers/e2b.js +69 -0
- package/dist/providers/maritime.d.ts +38 -0
- package/dist/providers/maritime.js +240 -0
- package/dist/providers/modal.d.ts +10 -0
- package/dist/providers/modal.js +82 -0
- package/dist/providers/morph.d.ts +19 -0
- package/dist/providers/morph.js +181 -0
- package/dist/providers/runloop.d.ts +16 -0
- package/dist/providers/runloop.js +145 -0
- package/dist/providers/sprites.d.ts +15 -0
- package/dist/providers/sprites.js +135 -0
- package/dist/providers/tensorlake.d.ts +14 -0
- package/dist/providers/tensorlake.js +192 -0
- package/dist/providers/vercel.d.ts +12 -0
- package/dist/providers/vercel.js +87 -0
- package/dist/router.d.ts +65 -0
- package/dist/router.js +180 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +257 -0
- package/dist/sse.d.ts +6 -0
- package/dist/sse.js +33 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +19 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +37 -0
- package/package.json +113 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
5
|
+
import { ProviderError } from "../types.js";
|
|
6
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
7
|
+
const APP_NAME = process.env.SANDBOXROUTER_MODAL_APP ?? "sandboxrouter";
|
|
8
|
+
/** Modal via the `modal` JS SDK. The only adapter here that takes `gpu`. */
|
|
9
|
+
export class ModalProvider {
|
|
10
|
+
id = "modal";
|
|
11
|
+
client;
|
|
12
|
+
configured() {
|
|
13
|
+
return (!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || existsSync(join(homedir(), ".modal.toml"));
|
|
14
|
+
}
|
|
15
|
+
async modal() {
|
|
16
|
+
if (!this.client) {
|
|
17
|
+
const { ModalClient } = await optionalImport("modal", "modal");
|
|
18
|
+
this.client = new ModalClient();
|
|
19
|
+
}
|
|
20
|
+
return this.client;
|
|
21
|
+
}
|
|
22
|
+
async create(spec) {
|
|
23
|
+
const modal = await this.modal();
|
|
24
|
+
const app = await modal.apps.fromName(APP_NAME, { createIfMissing: true });
|
|
25
|
+
const image = modal.images.fromRegistry(spec.image?.modal ?? "python:3.13-slim");
|
|
26
|
+
const sb = await modal.sandboxes.create(app, image, {
|
|
27
|
+
cpu: spec.vcpu ?? DEFAULT_VCPU,
|
|
28
|
+
memoryMiB: Math.round((spec.memoryGb ?? DEFAULT_MEMORY_GB) * 1024),
|
|
29
|
+
gpu: spec.gpu,
|
|
30
|
+
timeoutMs: (spec.timeoutMinutes ?? 30) * 60_000,
|
|
31
|
+
env: spec.env,
|
|
32
|
+
});
|
|
33
|
+
return new ModalBox(sb);
|
|
34
|
+
}
|
|
35
|
+
async connect(nativeId) {
|
|
36
|
+
return new ModalBox(await (await this.modal()).sandboxes.fromId(nativeId));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Read a text stream to the end, passing each chunk to `on`. */
|
|
40
|
+
async function drain(stream, on) {
|
|
41
|
+
const reader = stream.getReader();
|
|
42
|
+
let out = "";
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
return out;
|
|
47
|
+
out += value;
|
|
48
|
+
on?.(value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class ModalBox {
|
|
52
|
+
sb;
|
|
53
|
+
nativeId;
|
|
54
|
+
constructor(sb) {
|
|
55
|
+
this.sb = sb;
|
|
56
|
+
this.nativeId = sb.sandboxId;
|
|
57
|
+
}
|
|
58
|
+
async exec(cmd, opts = {}) {
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
try {
|
|
61
|
+
const p = await this.sb.exec(["sh", "-c", cmd], {
|
|
62
|
+
workdir: opts.cwd,
|
|
63
|
+
env: opts.env,
|
|
64
|
+
timeoutMs: (opts.timeoutSeconds ?? 60) * 1000,
|
|
65
|
+
});
|
|
66
|
+
const [stdout, stderr, exitCode] = await Promise.all([drain(p.stdout, opts.onStdout), drain(p.stderr, opts.onStderr), p.wait()]);
|
|
67
|
+
return { exitCode, stdout, stderr, durationMs: Date.now() - t0 };
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
throw new ProviderError("modal", err?.message ?? String(err));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async writeFile(path, content) {
|
|
74
|
+
await this.sb.filesystem.writeBytes(toBytes(content), path);
|
|
75
|
+
}
|
|
76
|
+
async readFile(path) {
|
|
77
|
+
return await this.sb.filesystem.readBytes(path);
|
|
78
|
+
}
|
|
79
|
+
async kill() {
|
|
80
|
+
await this.sb.terminate();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Morph Cloud via `morphcloud`. Instances start from snapshots: by default a cached snapshot of
|
|
4
|
+
* morphvm-minimal sized to spec.vcpu / spec.memoryGb; set image.morph to a snapshot id to start
|
|
5
|
+
* from your own (its resources come from the snapshot).
|
|
6
|
+
*/
|
|
7
|
+
export declare class MorphProvider implements Provider {
|
|
8
|
+
private apiKey;
|
|
9
|
+
readonly id: "morph";
|
|
10
|
+
private client?;
|
|
11
|
+
constructor(apiKey?: string | undefined);
|
|
12
|
+
configured(): boolean;
|
|
13
|
+
private morph;
|
|
14
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
15
|
+
/** Reattach; a paused instance is resumed. */
|
|
16
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
17
|
+
/** Reuse (or create once) a morphvm-minimal snapshot with this shape, keyed by digest. */
|
|
18
|
+
private baseSnapshot;
|
|
19
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { ProviderError } from "../types.js";
|
|
2
|
+
import { dirname, optionalImport, shq, toBytes, wrapCommand } from "../util.js";
|
|
3
|
+
/** Base image for the default snapshot (Morph's minimal Ubuntu VM). */
|
|
4
|
+
const BASE_IMAGE = "morphvm-minimal";
|
|
5
|
+
const DISK_MB = 8192;
|
|
6
|
+
/** spec.env lives here inside the VM so it survives connect(); every exec sources it. */
|
|
7
|
+
const ENV_FILE = "/etc/sandboxrouter.env";
|
|
8
|
+
const READY_TIMEOUT_S = 300;
|
|
9
|
+
/**
|
|
10
|
+
* Morph Cloud via `morphcloud`. Instances start from snapshots: by default a cached snapshot of
|
|
11
|
+
* morphvm-minimal sized to spec.vcpu / spec.memoryGb; set image.morph to a snapshot id to start
|
|
12
|
+
* from your own (its resources come from the snapshot).
|
|
13
|
+
*/
|
|
14
|
+
export class MorphProvider {
|
|
15
|
+
apiKey;
|
|
16
|
+
id = "morph";
|
|
17
|
+
client;
|
|
18
|
+
constructor(apiKey = process.env.MORPH_API_KEY) {
|
|
19
|
+
this.apiKey = apiKey;
|
|
20
|
+
}
|
|
21
|
+
configured() {
|
|
22
|
+
return !!this.apiKey;
|
|
23
|
+
}
|
|
24
|
+
async morph() {
|
|
25
|
+
if (!this.client) {
|
|
26
|
+
const { MorphCloudClient } = await optionalImport("morphcloud", "morph");
|
|
27
|
+
this.client = new MorphCloudClient({ apiKey: this.apiKey });
|
|
28
|
+
}
|
|
29
|
+
return this.client;
|
|
30
|
+
}
|
|
31
|
+
async create(spec) {
|
|
32
|
+
const m = await this.morph();
|
|
33
|
+
try {
|
|
34
|
+
const snapshotId = spec.image?.morph ?? (await this.baseSnapshot(m, spec));
|
|
35
|
+
const inst = await m.instances.start({
|
|
36
|
+
snapshotId,
|
|
37
|
+
metadata: spec.metadata,
|
|
38
|
+
ttlSeconds: Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60)),
|
|
39
|
+
ttlAction: "stop",
|
|
40
|
+
timeout: READY_TIMEOUT_S,
|
|
41
|
+
});
|
|
42
|
+
const box = new MorphBox(inst);
|
|
43
|
+
if (spec.env && Object.keys(spec.env).length) {
|
|
44
|
+
try {
|
|
45
|
+
await box.writeFile(ENV_FILE, envFile(spec.env));
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
await inst.stop().catch(() => { });
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return box;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw wrap(err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Reattach; a paused instance is resumed. */
|
|
59
|
+
async connect(nativeId) {
|
|
60
|
+
const m = await this.morph();
|
|
61
|
+
try {
|
|
62
|
+
const inst = await m.instances.get({ instanceId: nativeId });
|
|
63
|
+
if (String(inst.status) === "paused")
|
|
64
|
+
await inst.resume();
|
|
65
|
+
if (String(inst.status) !== "ready")
|
|
66
|
+
await inst.waitUntilReady(READY_TIMEOUT_S);
|
|
67
|
+
return new MorphBox(inst);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
throw wrap(err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Reuse (or create once) a morphvm-minimal snapshot with this shape, keyed by digest. */
|
|
74
|
+
async baseSnapshot(m, spec) {
|
|
75
|
+
const vcpus = spec.vcpu ?? 2;
|
|
76
|
+
const memory = Math.round((spec.memoryGb ?? 4) * 1024);
|
|
77
|
+
const digest = `sandboxrouter-${BASE_IMAGE}-${vcpus}c-${memory}m-${DISK_MB}d`;
|
|
78
|
+
const existing = (await m.snapshots.list({ digest })).find((s) => String(s.status) === "ready");
|
|
79
|
+
if (existing)
|
|
80
|
+
return existing.id;
|
|
81
|
+
let snap = await m.snapshots.create({ imageId: BASE_IMAGE, vcpus, memory, diskSize: DISK_MB, digest });
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
while (String(snap.status) === "pending") {
|
|
84
|
+
if (Date.now() - t0 > READY_TIMEOUT_S * 1000)
|
|
85
|
+
throw new ProviderError("morph", `snapshot ${snap.id} not ready`);
|
|
86
|
+
await sleep(1000);
|
|
87
|
+
snap = await m.snapshots.get({ snapshotId: snap.id });
|
|
88
|
+
}
|
|
89
|
+
if (String(snap.status) !== "ready")
|
|
90
|
+
throw new ProviderError("morph", `snapshot ${snap.id} is ${snap.status}`);
|
|
91
|
+
return snap.id;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class MorphBox {
|
|
95
|
+
inst;
|
|
96
|
+
nativeId;
|
|
97
|
+
constructor(inst) {
|
|
98
|
+
this.inst = inst;
|
|
99
|
+
this.nativeId = inst.id;
|
|
100
|
+
}
|
|
101
|
+
async exec(cmd, opts = {}) {
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
const script = `[ -r ${ENV_FILE} ] && . ${ENV_FILE}; ${wrapCommand(cmd, { cwd: opts.cwd, env: opts.env })}`;
|
|
104
|
+
try {
|
|
105
|
+
// With callbacks the SDK switches to its SSE endpoint and streams; otherwise one request.
|
|
106
|
+
const r = await this.inst.exec(["sh", "-c", script], {
|
|
107
|
+
timeout: opts.timeoutSeconds ?? 60,
|
|
108
|
+
onStdout: opts.onStdout,
|
|
109
|
+
onStderr: opts.onStderr,
|
|
110
|
+
});
|
|
111
|
+
return { exitCode: r.exit_code, stdout: r.stdout ?? "", stderr: r.stderr ?? "", durationMs: Date.now() - t0 };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
throw wrap(err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Over SSH: base64 on stdin, decoded in the VM (parent dirs created). */
|
|
118
|
+
async writeFile(path, content) {
|
|
119
|
+
const b64 = Buffer.from(toBytes(content)).toString("base64");
|
|
120
|
+
const r = await this.ssh((s) => s.execCommand(`mkdir -p ${shq(dirname(path))} && base64 -d > ${shq(path)}${path === ENV_FILE ? ` && chmod 600 ${ENV_FILE}` : ""}`, { stdin: b64 }));
|
|
121
|
+
if (r.code !== 0)
|
|
122
|
+
throw new ProviderError("morph", `writeFile ${path}: ${r.stderr || `exit ${r.code}`}`);
|
|
123
|
+
}
|
|
124
|
+
async readFile(path) {
|
|
125
|
+
const r = await this.ssh((s) => s.execCommand(`base64 < ${shq(path)}`));
|
|
126
|
+
if (r.code !== 0)
|
|
127
|
+
throw new ProviderError("morph", `readFile ${path}: ${r.stderr || `exit ${r.code}`}`);
|
|
128
|
+
return new Uint8Array(Buffer.from(r.stdout, "base64"));
|
|
129
|
+
}
|
|
130
|
+
/** Snapshot memory and disk. router.get() resumes it. */
|
|
131
|
+
async pause() {
|
|
132
|
+
try {
|
|
133
|
+
await this.inst.pause();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw wrap(err);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async kill() {
|
|
140
|
+
try {
|
|
141
|
+
await this.inst.stop();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
throw wrap(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** One SSH connection per call so no socket keeps the process alive. */
|
|
148
|
+
async ssh(fn) {
|
|
149
|
+
let s;
|
|
150
|
+
try {
|
|
151
|
+
s = await this.inst.ssh();
|
|
152
|
+
return await fn(s);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
throw wrap(err);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
s?.dispose();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function envFile(env) {
|
|
163
|
+
return Object.entries(env)
|
|
164
|
+
.map(([k, v]) => {
|
|
165
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
|
|
166
|
+
throw new Error(`invalid env var name: ${k}`);
|
|
167
|
+
return `export ${k}=${shq(v)}\n`;
|
|
168
|
+
})
|
|
169
|
+
.join("");
|
|
170
|
+
}
|
|
171
|
+
/** The SDK throws plain Errors whose message starts "HTTP <status> ...". */
|
|
172
|
+
function wrap(err) {
|
|
173
|
+
if (err instanceof ProviderError)
|
|
174
|
+
return err;
|
|
175
|
+
const msg = err?.message ?? String(err);
|
|
176
|
+
const status = /^HTTP (\d{3})/.exec(msg)?.[1];
|
|
177
|
+
return new ProviderError("morph", msg, status ? Number(status) : undefined);
|
|
178
|
+
}
|
|
179
|
+
function sleep(ms) {
|
|
180
|
+
return new Promise((r) => setTimeout(r, ms).unref());
|
|
181
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runloop devboxes via `@runloop/api-client`. Auth: RUNLOOP_API_KEY. Default Runloop image;
|
|
4
|
+
* set image.runloop to a blueprint name (or a `bpt_` blueprint id) to start from your own.
|
|
5
|
+
*/
|
|
6
|
+
export declare class RunloopProvider implements Provider {
|
|
7
|
+
private apiKey;
|
|
8
|
+
readonly id: "runloop";
|
|
9
|
+
private client?;
|
|
10
|
+
constructor(apiKey?: string | undefined);
|
|
11
|
+
configured(): boolean;
|
|
12
|
+
private runloop;
|
|
13
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
14
|
+
/** Reattach; a suspended devbox is resumed. */
|
|
15
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
2
|
+
import { ProviderError } from "../types.js";
|
|
3
|
+
import { optionalImport, toBytes, wrapCommand } from "../util.js";
|
|
4
|
+
/** Longest lifetime Runloop accepts for keep_alive_time_seconds (48 h). */
|
|
5
|
+
const MAX_KEEP_ALIVE_SECONDS = 172_800;
|
|
6
|
+
/**
|
|
7
|
+
* Runloop devboxes via `@runloop/api-client`. Auth: RUNLOOP_API_KEY. Default Runloop image;
|
|
8
|
+
* set image.runloop to a blueprint name (or a `bpt_` blueprint id) to start from your own.
|
|
9
|
+
*/
|
|
10
|
+
export class RunloopProvider {
|
|
11
|
+
apiKey;
|
|
12
|
+
id = "runloop";
|
|
13
|
+
client;
|
|
14
|
+
constructor(apiKey = process.env.RUNLOOP_API_KEY) {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
}
|
|
17
|
+
configured() {
|
|
18
|
+
return !!this.apiKey;
|
|
19
|
+
}
|
|
20
|
+
async runloop() {
|
|
21
|
+
if (!this.client) {
|
|
22
|
+
const { RunloopSDK } = await optionalImport("@runloop/api-client", "runloop");
|
|
23
|
+
this.client = new RunloopSDK({ bearerToken: this.apiKey });
|
|
24
|
+
}
|
|
25
|
+
return this.client;
|
|
26
|
+
}
|
|
27
|
+
async create(spec) {
|
|
28
|
+
const rl = await this.runloop();
|
|
29
|
+
const { cpu, memory } = size(spec.vcpu ?? DEFAULT_VCPU, spec.memoryGb ?? DEFAULT_MEMORY_GB);
|
|
30
|
+
const image = spec.image?.runloop;
|
|
31
|
+
try {
|
|
32
|
+
const box = await rl.devbox.create({
|
|
33
|
+
...(image ? (image.startsWith("bpt_") ? { blueprint_id: image } : { blueprint_name: image }) : {}),
|
|
34
|
+
environment_variables: spec.env,
|
|
35
|
+
metadata: spec.metadata,
|
|
36
|
+
launch_parameters: {
|
|
37
|
+
resource_size_request: "CUSTOM_SIZE",
|
|
38
|
+
custom_cpu_cores: cpu,
|
|
39
|
+
custom_gb_memory: memory,
|
|
40
|
+
keep_alive_time_seconds: Math.min(MAX_KEEP_ALIVE_SECONDS, Math.max(60, Math.round((spec.timeoutMinutes ?? 30) * 60))),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return new RunloopBox(box);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Reattach; a suspended devbox is resumed. */
|
|
50
|
+
async connect(nativeId) {
|
|
51
|
+
const box = (await this.runloop()).devbox.fromId(nativeId);
|
|
52
|
+
try {
|
|
53
|
+
const { status } = await box.getInfo();
|
|
54
|
+
if (status === "suspending")
|
|
55
|
+
await box.awaitSuspended();
|
|
56
|
+
if (status === "suspending" || status === "suspended")
|
|
57
|
+
await box.resume();
|
|
58
|
+
else if (status === "resuming" || status === "provisioning" || status === "initializing" || status === "queued" || status === "scheduled") {
|
|
59
|
+
await box.awaitRunning();
|
|
60
|
+
}
|
|
61
|
+
else if (status === "shutdown" || status === "failure") {
|
|
62
|
+
throw new ProviderError("runloop", `devbox ${nativeId} is ${status}`);
|
|
63
|
+
}
|
|
64
|
+
return new RunloopBox(box);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err instanceof ProviderError)
|
|
68
|
+
throw err;
|
|
69
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
class RunloopBox {
|
|
74
|
+
box;
|
|
75
|
+
nativeId;
|
|
76
|
+
constructor(box) {
|
|
77
|
+
this.box = box;
|
|
78
|
+
this.nativeId = box.id;
|
|
79
|
+
}
|
|
80
|
+
/** Runloop's exec has no cwd/env/timeout, so cwd/env are wrapped into the command and the timeout is enforced here. */
|
|
81
|
+
async exec(cmd, opts = {}) {
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
const limit = (opts.timeoutSeconds ?? 60) * 1000;
|
|
84
|
+
let execution;
|
|
85
|
+
try {
|
|
86
|
+
execution = await this.box.cmd.execAsync(wrapCommand(cmd, { cwd: opts.cwd, env: opts.env }), {
|
|
87
|
+
stdout: opts.onStdout,
|
|
88
|
+
stderr: opts.onStderr,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const r = await execution.result({ longPoll: { timeoutMs: limit } });
|
|
96
|
+
// Read the output from the result, not the stream, so a dropped stream can't lose output.
|
|
97
|
+
const [stdout, stderr] = await Promise.all([r.stdout(), r.stderr()]);
|
|
98
|
+
return { exitCode: r.exitCode ?? -1, stdout, stderr, durationMs: Date.now() - t0 };
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (Date.now() - t0 >= limit) {
|
|
102
|
+
await execution.kill().catch(() => { });
|
|
103
|
+
throw new ProviderError("runloop", `command timed out after ${limit / 1000}s`);
|
|
104
|
+
}
|
|
105
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async writeFile(path, content) {
|
|
109
|
+
const { toFile } = await optionalImport("@runloop/api-client", "runloop");
|
|
110
|
+
const name = path.slice(path.lastIndexOf("/") + 1) || "file";
|
|
111
|
+
try {
|
|
112
|
+
await this.box.file.upload({ path, file: await toFile(Buffer.from(toBytes(content)), name) });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async readFile(path) {
|
|
119
|
+
try {
|
|
120
|
+
const res = await this.box.file.download({ path });
|
|
121
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
throw new ProviderError("runloop", err?.message ?? String(err), err?.status);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Suspend and snapshot the disk. router.get() resumes it. */
|
|
128
|
+
async pause() {
|
|
129
|
+
await this.box.suspend();
|
|
130
|
+
await this.box.awaitSuspended();
|
|
131
|
+
}
|
|
132
|
+
async kill() {
|
|
133
|
+
await this.box.shutdown();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Snap a request to Runloop's CUSTOM_SIZE grid: CPU 0.5, 1 or a multiple of 2 (max 16);
|
|
138
|
+
* memory 1 or a multiple of 2 GiB (max 64); memory between 2x and 8x the CPU count.
|
|
139
|
+
*/
|
|
140
|
+
function size(vcpu, memoryGb) {
|
|
141
|
+
const cpu = vcpu <= 0.5 ? 0.5 : vcpu <= 1 ? 1 : Math.min(16, 2 * Math.ceil(vcpu / 2));
|
|
142
|
+
const m = Math.min(8 * cpu, Math.max(2 * cpu, memoryGb));
|
|
143
|
+
const memory = Math.min(64, m <= 1 ? 1 : 2 * Math.ceil(m / 2));
|
|
144
|
+
return { cpu, memory };
|
|
145
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fly.io Sprites via `@fly/sprites`. A sprite is a persistent machine named by the caller, so the
|
|
4
|
+
* name is the native id. Sprites idle on their own and have no lifetime knob: kill() deletes it.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SpritesProvider implements Provider {
|
|
7
|
+
private token;
|
|
8
|
+
readonly id: "sprites";
|
|
9
|
+
private client?;
|
|
10
|
+
constructor(token?: string | undefined);
|
|
11
|
+
configured(): boolean;
|
|
12
|
+
private sprites;
|
|
13
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
14
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { ProviderError } from "../types.js";
|
|
4
|
+
import { optionalImport, toBytes } from "../util.js";
|
|
5
|
+
/**
|
|
6
|
+
* Fly.io Sprites via `@fly/sprites`. A sprite is a persistent machine named by the caller, so the
|
|
7
|
+
* name is the native id. Sprites idle on their own and have no lifetime knob: kill() deletes it.
|
|
8
|
+
*/
|
|
9
|
+
export class SpritesProvider {
|
|
10
|
+
token;
|
|
11
|
+
id = "sprites";
|
|
12
|
+
client;
|
|
13
|
+
constructor(token = process.env.SPRITES_TOKEN) {
|
|
14
|
+
this.token = token;
|
|
15
|
+
}
|
|
16
|
+
configured() {
|
|
17
|
+
return !!this.token;
|
|
18
|
+
}
|
|
19
|
+
async sprites() {
|
|
20
|
+
if (!this.client) {
|
|
21
|
+
const { SpritesClient } = await optionalImport("@fly/sprites", "sprites");
|
|
22
|
+
this.client = new SpritesClient(this.token);
|
|
23
|
+
}
|
|
24
|
+
return this.client;
|
|
25
|
+
}
|
|
26
|
+
async create(spec) {
|
|
27
|
+
const client = await this.sprites();
|
|
28
|
+
const name = `sbr-${randomUUID().slice(0, 12)}`;
|
|
29
|
+
try {
|
|
30
|
+
const sprite = await client.createSprite(name, {
|
|
31
|
+
config: { cpus: spec.vcpu, ramMB: spec.memoryGb ? Math.round(spec.memoryGb * 1024) : undefined },
|
|
32
|
+
environment: spec.env,
|
|
33
|
+
});
|
|
34
|
+
return new SpritesBox(sprite);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new ProviderError("sprites", err?.message ?? String(err), err?.statusCode);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async connect(nativeId) {
|
|
41
|
+
const client = await this.sprites();
|
|
42
|
+
try {
|
|
43
|
+
return new SpritesBox(await client.getSprite(nativeId));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new ProviderError("sprites", err?.message ?? String(err), err?.statusCode);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class SpritesBox {
|
|
51
|
+
sprite;
|
|
52
|
+
nativeId;
|
|
53
|
+
constructor(sprite) {
|
|
54
|
+
this.sprite = sprite;
|
|
55
|
+
this.nativeId = sprite.name;
|
|
56
|
+
}
|
|
57
|
+
/** `sprite.exec` splits on whitespace without a shell, so spawn `sh -c` and stream its output. */
|
|
58
|
+
async exec(cmd, opts = {}) {
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
const limit = (opts.timeoutSeconds ?? 60) * 1000;
|
|
61
|
+
const proc = this.sprite.spawn("sh", ["-c", cmd], { cwd: opts.cwd, env: opts.env });
|
|
62
|
+
const out = new StringDecoder("utf8");
|
|
63
|
+
const errd = new StringDecoder("utf8");
|
|
64
|
+
let stdout = "";
|
|
65
|
+
let stderr = "";
|
|
66
|
+
proc.stdout.on("data", (b) => {
|
|
67
|
+
const c = out.write(b);
|
|
68
|
+
stdout += c;
|
|
69
|
+
if (c)
|
|
70
|
+
opts.onStdout?.(c);
|
|
71
|
+
});
|
|
72
|
+
proc.stderr.on("data", (b) => {
|
|
73
|
+
const c = errd.write(b);
|
|
74
|
+
stderr += c;
|
|
75
|
+
if (c)
|
|
76
|
+
opts.onStderr?.(c);
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
proc.kill("SIGKILL");
|
|
82
|
+
proc.close();
|
|
83
|
+
reject(new ProviderError("sprites", `command timed out after ${limit / 1000}s`));
|
|
84
|
+
}, limit);
|
|
85
|
+
timer.unref();
|
|
86
|
+
proc.on("exit", (code) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve(code);
|
|
89
|
+
});
|
|
90
|
+
proc.on("error", (e) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
proc.close();
|
|
93
|
+
reject(e);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const so = out.end();
|
|
97
|
+
const se = errd.end();
|
|
98
|
+
if (so)
|
|
99
|
+
opts.onStdout?.(so);
|
|
100
|
+
if (se)
|
|
101
|
+
opts.onStderr?.(se);
|
|
102
|
+
return { exitCode, stdout: stdout + so, stderr: stderr + se, durationMs: Date.now() - t0 };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err instanceof ProviderError)
|
|
106
|
+
throw err;
|
|
107
|
+
throw new ProviderError("sprites", err?.message ?? String(err));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async writeFile(path, content) {
|
|
111
|
+
try {
|
|
112
|
+
await this.sprite.filesystem().writeFile(path, Buffer.from(toBytes(content)));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new ProviderError("sprites", err?.message ?? String(err));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async readFile(path) {
|
|
119
|
+
try {
|
|
120
|
+
return new Uint8Array(await this.sprite.filesystem().readFile(path, null));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
throw new ProviderError("sprites", err?.message ?? String(err));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Sprites are persistent; delete so the router's disposable sandboxes don't linger. */
|
|
127
|
+
async kill() {
|
|
128
|
+
try {
|
|
129
|
+
await this.sprite.delete();
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw new ProviderError("sprites", err?.message ?? String(err), err?.statusCode);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Provider, type ProviderSandbox, type SandboxSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tensorlake sandboxes via `@tensorlakeai/tensorlake`. Auth: TENSORLAKE_API_KEY.
|
|
4
|
+
* Set image.tensorlake to start from your own sandbox image; spec.gpu maps to a GPU model.
|
|
5
|
+
*/
|
|
6
|
+
export declare class TensorlakeProvider implements Provider {
|
|
7
|
+
private apiKey;
|
|
8
|
+
readonly id: "tensorlake";
|
|
9
|
+
constructor(apiKey?: string | undefined);
|
|
10
|
+
configured(): boolean;
|
|
11
|
+
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
12
|
+
/** Reattach; a suspended sandbox is resumed. */
|
|
13
|
+
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
14
|
+
}
|