@sandboxrouter/sdk 0.5.0 → 0.6.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/dist/client.d.ts +9 -3
- package/dist/client.js +12 -5
- package/dist/computers.d.ts +47 -4
- package/dist/computers.js +130 -17
- package/dist/gc.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ledger.d.ts +3 -1
- package/dist/router.d.ts +3 -4
- package/dist/router.js +9 -28
- package/dist/server.d.ts +17 -1
- package/dist/server.js +141 -38
- package/dist/types.d.ts +2 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.js +21 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare class Client {
|
|
|
19
19
|
fetch?: typeof fetch;
|
|
20
20
|
});
|
|
21
21
|
/** @internal */
|
|
22
|
-
request(method: string, path: string, body?: unknown, accept?: string): Promise<Response>;
|
|
22
|
+
request(method: string, path: string, body?: unknown, accept?: string, headers?: Record<string, string>): Promise<Response>;
|
|
23
23
|
private json;
|
|
24
24
|
/** Providers this server can route to, with prices at the default size. */
|
|
25
25
|
providers(): Promise<{
|
|
@@ -31,8 +31,14 @@ export declare class Client {
|
|
|
31
31
|
}[]>;
|
|
32
32
|
/** Dry run: which providers would be tried, in order, each with its price, size and any reason it's skipped. */
|
|
33
33
|
route(spec?: SandboxSpec, route?: RouteOptions): Promise<Candidate[]>;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Start a sandbox on the first provider that works, as the server routes it. With `idempotencyKey`,
|
|
36
|
+
* sending the same create again (say, after a timeout) returns the same sandbox instead of starting
|
|
37
|
+
* a second one; use a new key for each sandbox you mean to start.
|
|
38
|
+
*/
|
|
39
|
+
create(spec?: SandboxSpec, route?: RouteOptions, opts?: {
|
|
40
|
+
idempotencyKey?: string;
|
|
41
|
+
}): Promise<RemoteSandbox>;
|
|
36
42
|
/** A handle on a sandbox you created earlier. */
|
|
37
43
|
sandbox(id: string): RemoteSandbox;
|
|
38
44
|
}
|
package/dist/client.js
CHANGED
|
@@ -17,7 +17,7 @@ export class Client {
|
|
|
17
17
|
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
18
18
|
}
|
|
19
19
|
/** @internal */
|
|
20
|
-
async request(method, path, body, accept = "application/json") {
|
|
20
|
+
async request(method, path, body, accept = "application/json", headers = {}) {
|
|
21
21
|
const raw = body instanceof Uint8Array;
|
|
22
22
|
const res = await this.fetch(this.baseUrl + path, {
|
|
23
23
|
method,
|
|
@@ -25,6 +25,7 @@ export class Client {
|
|
|
25
25
|
Accept: accept,
|
|
26
26
|
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
27
27
|
...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
|
|
28
|
+
...headers,
|
|
28
29
|
},
|
|
29
30
|
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
|
30
31
|
});
|
|
@@ -50,10 +51,16 @@ export class Client {
|
|
|
50
51
|
async route(spec = {}, route = {}) {
|
|
51
52
|
return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Start a sandbox on the first provider that works, as the server routes it. With `idempotencyKey`,
|
|
56
|
+
* sending the same create again (say, after a timeout) returns the same sandbox instead of starting
|
|
57
|
+
* a second one; use a new key for each sandbox you mean to start.
|
|
58
|
+
*/
|
|
59
|
+
async create(spec = {}, route = {}, opts = {}) {
|
|
60
|
+
const headers = opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {};
|
|
61
|
+
const res = await this.request("POST", "/v1/sandboxes", { ...spec, ...route }, "application/json", headers);
|
|
62
|
+
const r = (await res.json());
|
|
63
|
+
return new RemoteSandbox(this, r.id, r.provider, { ...r, replayed: res.headers.get("idempotent-replayed") === "true" });
|
|
57
64
|
}
|
|
58
65
|
/** A handle on a sandbox you created earlier. */
|
|
59
66
|
sandbox(id) {
|
package/dist/computers.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Ledger } from "./ledger.js";
|
|
2
|
+
import { type Attempt } from "./types.js";
|
|
1
3
|
/**
|
|
2
4
|
* Computers: machines with a screen that a model drives with clicks and keys (computer use).
|
|
3
5
|
* Linux and Windows desktops, Android phones. Routed like sandboxes: cheapest published price for
|
|
@@ -99,8 +101,17 @@ export interface ComputerProvider {
|
|
|
99
101
|
export declare class RoutedComputer {
|
|
100
102
|
readonly provider: ComputerProviderId;
|
|
101
103
|
private c;
|
|
104
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
105
|
+
readonly attempts: Attempt[];
|
|
106
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
107
|
+
readonly estimatedHourlyUsd?: number | undefined;
|
|
108
|
+
private ledger?;
|
|
102
109
|
readonly id: string;
|
|
103
|
-
constructor(provider: ComputerProviderId, c: ProviderComputer
|
|
110
|
+
constructor(provider: ComputerProviderId, c: ProviderComputer,
|
|
111
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
112
|
+
attempts?: Attempt[],
|
|
113
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
114
|
+
estimatedHourlyUsd?: number | undefined, ledger?: Ledger | undefined);
|
|
104
115
|
get nativeId(): string;
|
|
105
116
|
info(): Promise<ComputerInfo>;
|
|
106
117
|
act(action: ComputerAction): Promise<ActionResult>;
|
|
@@ -109,7 +120,10 @@ export declare class RoutedComputer {
|
|
|
109
120
|
format?: "png" | "jpeg";
|
|
110
121
|
quality?: number;
|
|
111
122
|
}): Promise<Uint8Array<ArrayBufferLike>>;
|
|
112
|
-
|
|
123
|
+
/** Run a shell command (max 30 s). The second argument is `{ timeoutSeconds }`, or the number alone. */
|
|
124
|
+
exec(command: string, opts?: number | {
|
|
125
|
+
timeoutSeconds?: number;
|
|
126
|
+
}): Promise<{
|
|
113
127
|
exitCode: number;
|
|
114
128
|
stdout: string;
|
|
115
129
|
stderr: string;
|
|
@@ -129,7 +143,12 @@ export declare class RoutedComputer {
|
|
|
129
143
|
expiresAt: string | null;
|
|
130
144
|
}>;
|
|
131
145
|
sleep(): Promise<void>;
|
|
146
|
+
/** Delete the computer and everything on it (its files and logins). */
|
|
132
147
|
delete(): Promise<void>;
|
|
148
|
+
/** Same as delete(), named like a sandbox's. */
|
|
149
|
+
kill(): Promise<void>;
|
|
150
|
+
/** `await using pc = await router.computer(...)` deletes it when the block ends. */
|
|
151
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
133
152
|
}
|
|
134
153
|
/** Published prices per OS for every computer provider. Same rules as the sandbox catalog. */
|
|
135
154
|
export interface ComputerCatalogEntry {
|
|
@@ -159,8 +178,32 @@ export interface ComputerCandidate {
|
|
|
159
178
|
}
|
|
160
179
|
/** The order a computer create would try, with the reason any provider is skipped. */
|
|
161
180
|
export declare function planComputer(providers: ComputerProvider[], os?: ComputerOs, prefer?: ComputerProviderId[]): ComputerCandidate[];
|
|
181
|
+
/** How a computer create is routed: the same knobs and rules as a sandbox's (see RouteOptions). */
|
|
182
|
+
export interface ComputerRoute {
|
|
183
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
184
|
+
/** true (default): move on when a provider is down or can't bill, stop on a setup error. "any": move on always. false: first only. */
|
|
185
|
+
fallback?: boolean | "any";
|
|
186
|
+
/** Seconds to wait for one provider before trying the next (default 120). */
|
|
187
|
+
createTimeoutSeconds?: number;
|
|
188
|
+
/** Seconds the whole create may take (default 240). */
|
|
189
|
+
deadlineSeconds?: number;
|
|
190
|
+
}
|
|
191
|
+
/** Split and check a POST /v1/computers body, like splitRequest does for sandboxes. */
|
|
192
|
+
export declare function splitComputerRequest(body: Record<string, unknown>): {
|
|
193
|
+
spec: ComputerSpec;
|
|
194
|
+
route: ComputerRoute;
|
|
195
|
+
};
|
|
162
196
|
/**
|
|
163
197
|
* Start a computer on the cheapest provider that offers this OS and has credentials (ties
|
|
164
|
-
* alphabetical), falling back down the list
|
|
198
|
+
* alphabetical), falling back down the list like a sandbox create: on to the next when a provider is
|
|
199
|
+
* down, slow or can't bill, but stopping on a setup error (a bad key or request). An explicit
|
|
200
|
+
* provider list keeps its order. Each computer goes in the ledger, so `ls` and `gc --all` find it;
|
|
201
|
+
* computers have no lifetime of their own, so plain `gc` leaves them.
|
|
165
202
|
*/
|
|
166
|
-
export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ComputerProviderId[]
|
|
203
|
+
export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ComputerProviderId[], route?: ComputerRoute & {
|
|
204
|
+
ledger?: Ledger;
|
|
205
|
+
}): Promise<RoutedComputer>;
|
|
206
|
+
/** Seconds to wait for one provider's computer before trying the next. */
|
|
207
|
+
export declare const DEFAULT_COMPUTER_TIMEOUT_S = 120;
|
|
208
|
+
/** Seconds a whole computer create may take. */
|
|
209
|
+
export declare const DEFAULT_COMPUTER_DEADLINE_S = 240;
|
package/dist/computers.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { RouterError } from "./types.js";
|
|
1
|
+
import { ProviderError, RouterError, SetupError } from "./types.js";
|
|
2
|
+
import { SETUP_ERRORS, errorStatus, withDeadline } from "./util.js";
|
|
2
3
|
export class RoutedComputer {
|
|
3
4
|
provider;
|
|
4
5
|
c;
|
|
6
|
+
attempts;
|
|
7
|
+
estimatedHourlyUsd;
|
|
8
|
+
ledger;
|
|
5
9
|
id;
|
|
6
|
-
constructor(provider, c
|
|
10
|
+
constructor(provider, c,
|
|
11
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
12
|
+
attempts = [],
|
|
13
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
14
|
+
estimatedHourlyUsd, ledger) {
|
|
7
15
|
this.provider = provider;
|
|
8
16
|
this.c = c;
|
|
17
|
+
this.attempts = attempts;
|
|
18
|
+
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
19
|
+
this.ledger = ledger;
|
|
9
20
|
this.id = `${provider}:${c.nativeId}`;
|
|
10
21
|
}
|
|
11
22
|
get nativeId() {
|
|
@@ -25,8 +36,9 @@ export class RoutedComputer {
|
|
|
25
36
|
screenshot(opts) {
|
|
26
37
|
return this.c.screenshot(opts);
|
|
27
38
|
}
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
/** Run a shell command (max 30 s). The second argument is `{ timeoutSeconds }`, or the number alone. */
|
|
40
|
+
exec(command, opts) {
|
|
41
|
+
return this.c.exec(command, typeof opts === "number" ? opts : opts?.timeoutSeconds);
|
|
30
42
|
}
|
|
31
43
|
readFile(path) {
|
|
32
44
|
return this.c.readFile(path);
|
|
@@ -48,8 +60,18 @@ export class RoutedComputer {
|
|
|
48
60
|
throw new RouterError(`${this.provider} can't sleep a computer; delete it instead`);
|
|
49
61
|
await this.c.sleep();
|
|
50
62
|
}
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
/** Delete the computer and everything on it (its files and logins). */
|
|
64
|
+
async delete() {
|
|
65
|
+
await this.c.delete();
|
|
66
|
+
this.ledger?.close(this.id, "deleted");
|
|
67
|
+
}
|
|
68
|
+
/** Same as delete(), named like a sandbox's. */
|
|
69
|
+
kill() {
|
|
70
|
+
return this.delete();
|
|
71
|
+
}
|
|
72
|
+
/** `await using pc = await router.computer(...)` deletes it when the block ends. */
|
|
73
|
+
async [Symbol.asyncDispose]() {
|
|
74
|
+
await this.delete();
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
export const COMPUTER_ENTRIES = [
|
|
@@ -166,27 +188,118 @@ export function planComputer(providers, os = "linux", prefer) {
|
|
|
166
188
|
}
|
|
167
189
|
return list;
|
|
168
190
|
}
|
|
191
|
+
const OS_LIST = ["linux", "windows", "android", "ios"];
|
|
192
|
+
const SPEC_FIELDS = ["os", "externalUserId", "name", "screen", "arch"];
|
|
193
|
+
const ROUTE_FIELDS = ["provider", "fallback", "createTimeoutSeconds", "deadlineSeconds"];
|
|
194
|
+
/** Split and check a POST /v1/computers body, like splitRequest does for sandboxes. */
|
|
195
|
+
export function splitComputerRequest(body) {
|
|
196
|
+
const bad = (msg) => new RouterError(msg, [], "bad_request");
|
|
197
|
+
const spec = {};
|
|
198
|
+
const route = {};
|
|
199
|
+
const unknown = [];
|
|
200
|
+
for (const [k, v] of Object.entries(body ?? {})) {
|
|
201
|
+
if (SPEC_FIELDS.includes(k))
|
|
202
|
+
spec[k] = v;
|
|
203
|
+
else if (ROUTE_FIELDS.includes(k))
|
|
204
|
+
route[k] = v;
|
|
205
|
+
else
|
|
206
|
+
unknown.push(k);
|
|
207
|
+
}
|
|
208
|
+
if (unknown.length)
|
|
209
|
+
throw bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: a computer takes ${[...SPEC_FIELDS, ...ROUTE_FIELDS].join(", ")}`);
|
|
210
|
+
if (spec.os !== undefined && !OS_LIST.includes(spec.os))
|
|
211
|
+
throw bad(`os must be one of ${OS_LIST.join(", ")}`);
|
|
212
|
+
for (const k of ["externalUserId", "name"])
|
|
213
|
+
if (spec[k] !== undefined && typeof spec[k] !== "string")
|
|
214
|
+
throw bad(`${k} must be a string`);
|
|
215
|
+
const screen = spec.screen;
|
|
216
|
+
if (screen !== undefined && !(typeof screen === "object" && [screen.width, screen.height].every((n) => typeof n === "number" && Number.isFinite(n) && n > 0))) {
|
|
217
|
+
throw bad("screen must be { width, height } in pixels");
|
|
218
|
+
}
|
|
219
|
+
if (spec.arch !== undefined && spec.arch !== "x86_64" && spec.arch !== "arm64")
|
|
220
|
+
throw bad('arch must be "x86_64" or "arm64"');
|
|
221
|
+
for (const k of ["createTimeoutSeconds", "deadlineSeconds"]) {
|
|
222
|
+
const v = route[k];
|
|
223
|
+
if (v !== undefined && !(typeof v === "number" && Number.isFinite(v) && v > 0))
|
|
224
|
+
throw bad(`${k} must be a positive number`);
|
|
225
|
+
}
|
|
226
|
+
if (route.fallback !== undefined && route.fallback !== true && route.fallback !== false && route.fallback !== "any")
|
|
227
|
+
throw bad('fallback must be true, false or "any"');
|
|
228
|
+
return { spec: spec, route: route };
|
|
229
|
+
}
|
|
169
230
|
/**
|
|
170
231
|
* Start a computer on the cheapest provider that offers this OS and has credentials (ties
|
|
171
|
-
* alphabetical), falling back down the list
|
|
232
|
+
* alphabetical), falling back down the list like a sandbox create: on to the next when a provider is
|
|
233
|
+
* down, slow or can't bill, but stopping on a setup error (a bad key or request). An explicit
|
|
234
|
+
* provider list keeps its order. Each computer goes in the ledger, so `ls` and `gc --all` find it;
|
|
235
|
+
* computers have no lifetime of their own, so plain `gc` leaves them.
|
|
172
236
|
*/
|
|
173
|
-
export async function createComputer(providers, spec = {}, prefer) {
|
|
237
|
+
export async function createComputer(providers, spec = {}, prefer, route = {}) {
|
|
174
238
|
const os = spec.os ?? "linux";
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
.map((c) => providers.find((p) => p.id === c.provider));
|
|
239
|
+
const plan = planComputer(providers, os, prefer);
|
|
240
|
+
const usable = plan.filter((c) => !c.skip);
|
|
178
241
|
if (usable.length === 0) {
|
|
179
242
|
const offering = providers.filter((p) => p.computerOs.includes(os)).map((p) => p.id);
|
|
180
|
-
throw new RouterError(offering.length ? `no configured provider for a ${os} computer (offered by: ${offering.join(", ")})` : `no provider offers a ${os} computer
|
|
243
|
+
throw new RouterError(offering.length ? `no configured provider for a ${os} computer (offered by: ${offering.join(", ")})` : `no provider offers a ${os} computer`, [], "no_provider");
|
|
244
|
+
}
|
|
245
|
+
if (prefer && route.fallback === false && plan[0].skip) {
|
|
246
|
+
throw new RouterError(`${plan[0].provider} can't start this computer: ${plan[0].skip}`, [], "no_provider");
|
|
181
247
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
248
|
+
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
249
|
+
const limitS = route.createTimeoutSeconds ?? DEFAULT_COMPUTER_TIMEOUT_S;
|
|
250
|
+
const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_COMPUTER_DEADLINE_S) * 1000;
|
|
251
|
+
const attempts = [];
|
|
252
|
+
for (const c of tries) {
|
|
253
|
+
const left = deadline - Date.now();
|
|
254
|
+
if (left <= 0)
|
|
255
|
+
throw new RouterError(`computer create ran out of time: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "timeout");
|
|
256
|
+
const p = providers.find((x) => x.id === c.provider);
|
|
257
|
+
const t0 = Date.now();
|
|
258
|
+
const pending = p.createComputer({ ...spec, os });
|
|
259
|
+
const waitS = Math.min(limitS, left / 1000);
|
|
260
|
+
let timedOut = false;
|
|
184
261
|
try {
|
|
185
|
-
|
|
262
|
+
const made = await withDeadline(pending, waitS * 1000, () => {
|
|
263
|
+
timedOut = true;
|
|
264
|
+
return new ProviderError(p.id, `create timed out after ${Number(waitS.toFixed(1))}s`);
|
|
265
|
+
});
|
|
266
|
+
attempts.push({ provider: p.id, ok: true, ms: Date.now() - t0 });
|
|
267
|
+
const pc = new RoutedComputer(p.id, made, attempts, c.estimatedHourlyUsd, route.ledger);
|
|
268
|
+
record(route.ledger, pc);
|
|
269
|
+
return pc;
|
|
186
270
|
}
|
|
187
271
|
catch (err) {
|
|
188
|
-
|
|
272
|
+
// One that turns up after we gave up is deleted, so it can't bill unseen (or left in the ledger for gc).
|
|
273
|
+
if (timedOut) {
|
|
274
|
+
pending.then((late) => {
|
|
275
|
+
const pc = new RoutedComputer(p.id, late, [], c.estimatedHourlyUsd, route.ledger);
|
|
276
|
+
record(route.ledger, pc);
|
|
277
|
+
return pc.delete();
|
|
278
|
+
}).catch(() => { });
|
|
279
|
+
}
|
|
280
|
+
const status = errorStatus(err);
|
|
281
|
+
attempts.push({ provider: p.id, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
|
|
282
|
+
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
283
|
+
if (route.fallback !== "any" && setup) {
|
|
284
|
+
throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${p.id} (key or request), so the router stopped instead of moving to another provider; pass fallback: "any" to fall back on every error`, attempts, "setup");
|
|
285
|
+
}
|
|
189
286
|
}
|
|
190
287
|
}
|
|
191
|
-
throw new RouterError(`all computer providers failed: ${
|
|
288
|
+
throw new RouterError(`all computer providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "all_failed");
|
|
289
|
+
}
|
|
290
|
+
/** Computers keep running until deleted: the ledger gives them no lifetime, so only `gc --all` takes them. */
|
|
291
|
+
const NO_LIFETIME = "9999-12-31T00:00:00.000Z";
|
|
292
|
+
function record(ledger, pc) {
|
|
293
|
+
ledger?.open({
|
|
294
|
+
id: pc.id,
|
|
295
|
+
provider: pc.provider,
|
|
296
|
+
kind: "computer",
|
|
297
|
+
createdAt: new Date().toISOString(),
|
|
298
|
+
expiresAt: NO_LIFETIME,
|
|
299
|
+
usdHour: pc.estimatedHourlyUsd ?? 0,
|
|
300
|
+
});
|
|
192
301
|
}
|
|
302
|
+
/** Seconds to wait for one provider's computer before trying the next. */
|
|
303
|
+
export const DEFAULT_COMPUTER_TIMEOUT_S = 120;
|
|
304
|
+
/** Seconds a whole computer create may take. */
|
|
305
|
+
export const DEFAULT_COMPUTER_DEADLINE_S = 240;
|
package/dist/gc.js
CHANGED
|
@@ -34,8 +34,11 @@ export async function gc(router, opts = {}) {
|
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
36
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Both close the ledger entry.
|
|
38
|
+
if (s.kind === "computer")
|
|
39
|
+
await (await router.getComputer(s.id)).delete();
|
|
40
|
+
else
|
|
41
|
+
await (await router.get(s.id)).kill();
|
|
39
42
|
results.push({ id: s.id, action: "killed" });
|
|
40
43
|
}
|
|
41
44
|
catch (err) {
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,9 @@ export { VERIFIED, FAILING } from "./verified.js";
|
|
|
7
7
|
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
8
|
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
9
9
|
export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
|
|
10
|
-
export { createApp, HttpError, ERROR_STATUS, type ServerHooks } from "./server.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, ORPHAN_GRACE_MS, type ServerHooks } from "./server.js";
|
|
11
11
|
export { Client, ClientError, RemoteSandbox, type CreateResponse } from "./client.js";
|
|
12
|
-
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, type ComputerCatalogEntry, type ComputerCandidate, type ComputerProviderId, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
|
|
12
|
+
export { RoutedComputer, createComputer, splitComputerRequest, DEFAULT_COMPUTER_TIMEOUT_S, DEFAULT_COMPUTER_DEADLINE_S, type ComputerRoute, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, type ComputerCatalogEntry, type ComputerCandidate, type ComputerProviderId, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
|
|
13
13
|
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
14
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
|
15
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,9 @@ export { VERIFIED, FAILING } from "./verified.js";
|
|
|
7
7
|
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
8
|
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
9
9
|
export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
|
|
10
|
-
export { createApp, HttpError, ERROR_STATUS } from "./server.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, ORPHAN_GRACE_MS } from "./server.js";
|
|
11
11
|
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
12
|
-
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
12
|
+
export { RoutedComputer, createComputer, splitComputerRequest, DEFAULT_COMPUTER_TIMEOUT_S, DEFAULT_COMPUTER_DEADLINE_S, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
13
13
|
export { bench, benchMarkdown } from "./bench.js";
|
|
14
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
15
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { ProviderId } from "./types.js";
|
|
|
3
3
|
export interface LedgerEntry {
|
|
4
4
|
/** Routed id, `provider:nativeId`. */
|
|
5
5
|
id: string;
|
|
6
|
-
provider: ProviderId;
|
|
6
|
+
provider: ProviderId | (string & {});
|
|
7
|
+
/** "computer" for desktops and phones (deleted with getComputer); sandboxes leave it out. */
|
|
8
|
+
kind?: "computer";
|
|
7
9
|
/** ISO time the create returned. */
|
|
8
10
|
createdAt: string;
|
|
9
11
|
/** ISO time its requested lifetime (spec.timeoutMinutes) runs out. */
|
package/dist/router.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
1
|
+
import { type ComputerRoute, RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
2
2
|
import { Ledger } from "./ledger.js";
|
|
3
3
|
import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
|
|
4
4
|
/** vCPUs and memory a sandbox gets. */
|
|
@@ -105,9 +105,8 @@ export declare class Router {
|
|
|
105
105
|
provider?: ComputerProviderId | ComputerProviderId[];
|
|
106
106
|
}): import("./computers.js").ComputerCandidate[];
|
|
107
107
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}): Promise<RoutedComputer>;
|
|
108
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
109
|
+
computer(spec?: ComputerSpec, route?: ComputerRoute): Promise<RoutedComputer>;
|
|
111
110
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
112
111
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
113
112
|
get(id: string): Promise<RoutedSandbox>;
|
package/dist/router.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CATALOG, DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, fixedSize, hourl
|
|
|
2
2
|
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
4
|
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
|
-
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, SETUP_ERRORS, errorStatus, timeoutError, withDeadline } from "./util.js";
|
|
6
6
|
import { BILLED_VS_LIST } from "./billing.js";
|
|
7
7
|
import { FAILING, VERIFIED } from "./verified.js";
|
|
8
8
|
import { validate } from "./validate.js";
|
|
@@ -309,19 +309,21 @@ export class Router {
|
|
|
309
309
|
return planComputer(this.computerProviders(), os, prefer);
|
|
310
310
|
}
|
|
311
311
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
313
|
+
computer(spec = {}, route = {}) {
|
|
314
|
+
const prefer = route.provider ? [].concat(route.provider) : undefined;
|
|
315
|
+
return createComputer(this.computerProviders(), spec, prefer, { ...route, ledger: this.ledger });
|
|
315
316
|
}
|
|
316
317
|
async getComputer(id) {
|
|
317
318
|
const i = id.indexOf(":");
|
|
318
319
|
if (i <= 0)
|
|
319
|
-
throw new RouterError(`bad computer id "${id}": expected provider:id
|
|
320
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`, [], "bad_request");
|
|
320
321
|
const provider = id.slice(0, i);
|
|
321
322
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
322
323
|
if (!p)
|
|
323
|
-
throw new RouterError(`${provider} does not offer computers
|
|
324
|
-
|
|
324
|
+
throw new RouterError(`${provider} does not offer computers`, [], "bad_request");
|
|
325
|
+
const e = this.ledger?.list().find((x) => x.id === id);
|
|
326
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)), [], e?.usdHour, this.ledger);
|
|
325
327
|
}
|
|
326
328
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
327
329
|
async get(id) {
|
|
@@ -437,29 +439,8 @@ export function balanced(list) {
|
|
|
437
439
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
438
440
|
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
439
441
|
export const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
440
|
-
/**
|
|
441
|
-
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
442
|
-
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
443
|
-
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
444
|
-
*/
|
|
445
|
-
const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
446
442
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
447
443
|
export const EXEC_GRACE_S = 30;
|
|
448
|
-
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
449
|
-
async function withDeadline(work, ms, onExpire) {
|
|
450
|
-
let timer;
|
|
451
|
-
const expired = new Promise((_, reject) => {
|
|
452
|
-
timer = setTimeout(() => reject(onExpire()), ms);
|
|
453
|
-
timer.unref();
|
|
454
|
-
});
|
|
455
|
-
try {
|
|
456
|
-
return await Promise.race([work, expired]);
|
|
457
|
-
}
|
|
458
|
-
finally {
|
|
459
|
-
clearTimeout(timer);
|
|
460
|
-
work.catch(() => { });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
444
|
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
464
445
|
export function sizeOf(id, spec = {}) {
|
|
465
446
|
return fixedSize(id, spec) ?? { vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
|
+
import { type ComputerSpec, type RoutedComputer } from "./computers.js";
|
|
3
4
|
import { type RouteOptions, type RouterErrorCode, type SandboxSpec } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Hooks for running this API for many users (accounts, billing, per-user sandboxes). With none set,
|
|
@@ -30,8 +31,21 @@ export interface ServerHooks {
|
|
|
30
31
|
afterKill?(caller: unknown, id: string, outcome?: {
|
|
31
32
|
error?: Error;
|
|
32
33
|
}): Promise<void>;
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* /v1/computers. Default: on for a single-user server, off when `authorize` is set. When on with
|
|
36
|
+
* `authorize`, `owns` guards computer ids too, and the computer hooks below run.
|
|
37
|
+
*/
|
|
34
38
|
computers?: boolean;
|
|
39
|
+
/** Before a computer starts. Throw HttpError to refuse; may edit spec. */
|
|
40
|
+
beforeCreateComputer?(caller: unknown, spec: ComputerSpec): Promise<void>;
|
|
41
|
+
/** After a computer started. Return fields to add to the answer. If this throws, the computer is deleted. */
|
|
42
|
+
afterCreateComputer?(caller: unknown, computer: RoutedComputer): Promise<Record<string, unknown> | void>;
|
|
43
|
+
/** After DELETE /v1/computers/:id; `error` is set when the provider's delete failed. */
|
|
44
|
+
afterDeleteComputer?(caller: unknown, id: string, outcome?: {
|
|
45
|
+
error?: Error;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/** Milliseconds a sandbox whose caller hung up (having sent an Idempotency-Key) waits for a retry to collect it. Default 120000. */
|
|
48
|
+
orphanGraceMs?: number;
|
|
35
49
|
/** false turns off pause. */
|
|
36
50
|
pause?: boolean;
|
|
37
51
|
}
|
|
@@ -68,5 +82,7 @@ export declare class HttpError extends Error {
|
|
|
68
82
|
readonly status: number;
|
|
69
83
|
constructor(status: number, message: string);
|
|
70
84
|
}
|
|
85
|
+
/** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
|
|
86
|
+
export declare const ORPHAN_GRACE_MS = 120000;
|
|
71
87
|
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
72
88
|
export declare const ERROR_STATUS: Record<RouterErrorCode, number>;
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { CATALOG } from "./catalog.js";
|
|
4
4
|
import { Router } from "./router.js";
|
|
5
|
+
import { splitComputerRequest } from "./computers.js";
|
|
5
6
|
import { ProviderError, RouterError } from "./types.js";
|
|
6
7
|
import { errorStatus } from "./util.js";
|
|
7
8
|
import { splitRequest } from "./validate.js";
|
|
@@ -49,6 +50,63 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
49
50
|
};
|
|
50
51
|
const live = new Map();
|
|
51
52
|
const computersOn = hooks.computers ?? !hooks.authorize;
|
|
53
|
+
/**
|
|
54
|
+
* Idempotency-Key: a create retried with the same key (by the same caller, to the same path) gets the
|
|
55
|
+
* first create's answer instead of starting another sandbox, for 24 hours. A retry that arrives while
|
|
56
|
+
* the first is still starting waits for it. The same key with a different body is refused (422).
|
|
57
|
+
* Failed creates aren't remembered, so retrying one runs it again.
|
|
58
|
+
*/
|
|
59
|
+
const replays = new Map();
|
|
60
|
+
const once = async (req, res, body, run) => {
|
|
61
|
+
const raw = req.headers["idempotency-key"];
|
|
62
|
+
if (raw === undefined) {
|
|
63
|
+
const r = await run(false);
|
|
64
|
+
return json(res, r.status, r.body);
|
|
65
|
+
}
|
|
66
|
+
const key = Array.isArray(raw) ? raw[0] : raw;
|
|
67
|
+
if (!key || key.length > 255)
|
|
68
|
+
throw new HttpError(400, "Idempotency-Key must be 1 to 255 characters");
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [k, v] of replays)
|
|
71
|
+
if (now - v.at > IDEMPOTENCY_TTL_MS)
|
|
72
|
+
replays.delete(k);
|
|
73
|
+
while (replays.size >= IDEMPOTENCY_MAX)
|
|
74
|
+
replays.delete(replays.keys().next().value);
|
|
75
|
+
const scope = sha256(`${req.headers.authorization ?? ""}\n${req.url}\n${key}`);
|
|
76
|
+
const bodyHash = sha256(JSON.stringify(body ?? {}));
|
|
77
|
+
const hit = replays.get(scope);
|
|
78
|
+
if (hit) {
|
|
79
|
+
if (hit.bodyHash !== bodyHash)
|
|
80
|
+
throw new HttpError(422, "this Idempotency-Key was already used with a different request");
|
|
81
|
+
const r = await hit.result;
|
|
82
|
+
hit.claimed = true;
|
|
83
|
+
res.setHeader("Idempotent-Replayed", "true");
|
|
84
|
+
return json(res, r.status, r.body);
|
|
85
|
+
}
|
|
86
|
+
const entry = { bodyHash, result: run(true), at: now, claimed: false };
|
|
87
|
+
replays.set(scope, entry);
|
|
88
|
+
let r;
|
|
89
|
+
try {
|
|
90
|
+
r = await entry.result;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
replays.delete(scope);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// The caller hung up but sent a key, so it means to retry: keep the sandbox for a retry to
|
|
97
|
+
// collect, and only then give up on it.
|
|
98
|
+
if (!res.writableEnded && (res.destroyed || !res.writable) && r.discard) {
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
if (!entry.claimed) {
|
|
101
|
+
replays.delete(scope);
|
|
102
|
+
r.discard().catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
}, hooks.orphanGraceMs ?? ORPHAN_GRACE_MS).unref();
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
entry.claimed = true;
|
|
108
|
+
return json(res, r.status, r.body);
|
|
109
|
+
};
|
|
52
110
|
const cors = (req, res) => {
|
|
53
111
|
const origin = req.headers.origin;
|
|
54
112
|
if (!origin || !(corsOrigins.includes("*") || corsOrigins.includes(origin)))
|
|
@@ -108,46 +166,59 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
108
166
|
if (parts[1] === "computers") {
|
|
109
167
|
if (!computersOn)
|
|
110
168
|
return json(res, 404, { error: "computers aren't available on this server" });
|
|
111
|
-
return computers(req, res, url, parts);
|
|
169
|
+
return computers(req, res, url, parts, caller);
|
|
112
170
|
}
|
|
113
171
|
if (parts[1] !== "sandboxes")
|
|
114
172
|
return json(res, 404, { error: "not found" });
|
|
115
173
|
// POST /v1/sandboxes
|
|
116
174
|
if (req.method === "POST" && parts.length === 2) {
|
|
117
|
-
const
|
|
118
|
-
|
|
175
|
+
const body = await readJson(req);
|
|
176
|
+
const { spec, route } = splitRequest(body);
|
|
119
177
|
// A caller that gives up (client timeout, closed connection) never learns the id: kill what
|
|
120
|
-
// starts after that rather than leave it running, and billing, unseen.
|
|
178
|
+
// starts after that rather than leave it running, and billing, unseen. With an Idempotency-Key
|
|
179
|
+
// it's kept for a retry instead (see once).
|
|
121
180
|
let hungUp = false;
|
|
122
181
|
res.on("close", () => (hungUp = !res.writableFinished));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
182
|
+
return once(req, res, body, async (keepIfHungUp) => {
|
|
183
|
+
await hooks.beforeCreate?.(caller, spec, route);
|
|
184
|
+
let sbx;
|
|
185
|
+
let extra;
|
|
186
|
+
try {
|
|
187
|
+
sbx = await router.create(spec, route);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
await hooks.createFailed?.(caller);
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
if (hungUp && !keepIfHungUp)
|
|
195
|
+
throw new HttpError(499, "the client closed the connection before the sandbox was ready; it was killed");
|
|
196
|
+
extra = await hooks.afterCreate?.(caller, sbx, spec);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
await sbx.kill().catch(() => { });
|
|
200
|
+
await hooks.createFailed?.(caller);
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
live.set(sbx.id, sbx);
|
|
204
|
+
const made = sbx;
|
|
205
|
+
return {
|
|
206
|
+
status: 201,
|
|
207
|
+
body: {
|
|
208
|
+
id: sbx.id,
|
|
209
|
+
provider: sbx.provider,
|
|
210
|
+
size: sbx.size,
|
|
211
|
+
estimatedHourlyUsd: sbx.estimatedHourlyUsd,
|
|
212
|
+
listHourlyUsd: sbx.listHourlyUsd,
|
|
213
|
+
attempts: sbx.attempts,
|
|
214
|
+
...(extra ?? {}),
|
|
215
|
+
},
|
|
216
|
+
discard: async () => {
|
|
217
|
+
live.delete(made.id);
|
|
218
|
+
await made.kill().catch(() => { });
|
|
219
|
+
await hooks.afterKill?.(caller, made.id);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
151
222
|
});
|
|
152
223
|
}
|
|
153
224
|
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
@@ -220,21 +291,47 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
220
291
|
}
|
|
221
292
|
return json(res, 404, { error: "not found" });
|
|
222
293
|
};
|
|
223
|
-
const computers = async (req, res, url, parts) => {
|
|
294
|
+
const computers = async (req, res, url, parts, caller) => {
|
|
224
295
|
if (req.method === "POST" && parts.length === 2) {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
return
|
|
296
|
+
const body = await readJson(req);
|
|
297
|
+
const { spec, route } = splitComputerRequest(body);
|
|
298
|
+
return once(req, res, body, async () => {
|
|
299
|
+
await hooks.beforeCreateComputer?.(caller, spec);
|
|
300
|
+
const c = await router.computer(spec, route);
|
|
301
|
+
let extra;
|
|
302
|
+
try {
|
|
303
|
+
extra = await hooks.afterCreateComputer?.(caller, c);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
await c.delete().catch(() => { });
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
status: 201,
|
|
311
|
+
body: { ...(await c.info()), id: c.id, nativeId: c.nativeId, provider: c.provider, estimatedHourlyUsd: c.estimatedHourlyUsd, attempts: c.attempts, ...(extra ?? {}) },
|
|
312
|
+
};
|
|
313
|
+
});
|
|
228
314
|
}
|
|
229
315
|
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
230
316
|
if (!id)
|
|
231
317
|
return json(res, 404, { error: "not found" });
|
|
318
|
+
if (hooks.owns && !(await hooks.owns(caller, id)))
|
|
319
|
+
throw new HttpError(404, "no such computer");
|
|
232
320
|
const c = await router.getComputer(id);
|
|
233
321
|
const sub = parts[3];
|
|
234
322
|
if (req.method === "GET" && !sub)
|
|
235
323
|
return json(res, 200, { ...(await c.info()), id, nativeId: c.nativeId });
|
|
236
324
|
if (req.method === "DELETE" && !sub) {
|
|
237
|
-
|
|
325
|
+
try {
|
|
326
|
+
await c.delete();
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
if (errorStatus(err) !== 404) {
|
|
330
|
+
await hooks.afterDeleteComputer?.(caller, id, { error: err });
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
await hooks.afterDeleteComputer?.(caller, id);
|
|
238
335
|
return json(res, 200, { id, deleted: true });
|
|
239
336
|
}
|
|
240
337
|
if (req.method === "POST" && sub === "actions") {
|
|
@@ -306,6 +403,12 @@ function describeError(err) {
|
|
|
306
403
|
const code = err instanceof RouterError ? err.code : undefined;
|
|
307
404
|
return { status, body: { error: err?.message ?? String(err), ...(code ? { code } : {}), ...(err?.attempts?.length ? { attempts: err.attempts } : {}) } };
|
|
308
405
|
}
|
|
406
|
+
/** How long an Idempotency-Key is remembered, and how many at most. */
|
|
407
|
+
const IDEMPOTENCY_TTL_MS = 24 * 3600_000;
|
|
408
|
+
const IDEMPOTENCY_MAX = 10_000;
|
|
409
|
+
/** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
|
|
410
|
+
export const ORPHAN_GRACE_MS = 120_000;
|
|
411
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
309
412
|
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
310
413
|
export const ERROR_STATUS = {
|
|
311
414
|
bad_request: 400,
|
package/dist/types.d.ts
CHANGED
|
@@ -96,7 +96,8 @@ export interface Provider {
|
|
|
96
96
|
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
97
97
|
}
|
|
98
98
|
export interface Attempt {
|
|
99
|
-
provider
|
|
99
|
+
/** A sandbox provider, or a computer provider (some sell only computers). */
|
|
100
|
+
provider: ProviderId | (string & {});
|
|
100
101
|
ok: boolean;
|
|
101
102
|
ms: number;
|
|
102
103
|
error?: string;
|
package/dist/util.d.ts
CHANGED
|
@@ -43,3 +43,11 @@ export declare function optionalImport<T>(pkg: string, provider: string): Promis
|
|
|
43
43
|
* (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
|
|
44
44
|
*/
|
|
45
45
|
export declare function errorStatus(err: unknown, depth?: number): number | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
48
|
+
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
49
|
+
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
50
|
+
*/
|
|
51
|
+
export declare const SETUP_ERRORS: Set<number>;
|
|
52
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
53
|
+
export declare function withDeadline<T>(work: Promise<T>, ms: number, onExpire: () => Error): Promise<T>;
|
package/dist/util.js
CHANGED
|
@@ -111,3 +111,24 @@ export function errorStatus(err, depth = 0) {
|
|
|
111
111
|
return 429;
|
|
112
112
|
return errorStatus(e.cause, depth + 1);
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
116
|
+
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
117
|
+
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
118
|
+
*/
|
|
119
|
+
export const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
120
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
121
|
+
export async function withDeadline(work, ms, onExpire) {
|
|
122
|
+
let timer;
|
|
123
|
+
const expired = new Promise((_, reject) => {
|
|
124
|
+
timer = setTimeout(() => reject(onExpire()), ms);
|
|
125
|
+
timer.unref();
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
return await Promise.race([work, expired]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
work.catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandboxrouter/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "One API for every sandbox provider: route AI agents' code by price, speed or capability, with automatic fallback. Free and open source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|