@solarisdk/runloop-compat 0.0.0-stage → 0.1.1

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.
Files changed (58) hide show
  1. package/COMPAT.md +112 -0
  2. package/LICENSE +17 -0
  3. package/LICENSE-RUNLOOP +7 -0
  4. package/NOTICE +14 -0
  5. package/README.md +46 -2
  6. package/dist/blueprint-types.d.ts +522 -0
  7. package/dist/blueprint-types.d.ts.map +1 -0
  8. package/dist/blueprint-types.js +4 -0
  9. package/dist/blueprint-types.js.map +1 -0
  10. package/dist/blueprints.d.ts +71 -0
  11. package/dist/blueprints.d.ts.map +1 -0
  12. package/dist/blueprints.js +233 -0
  13. package/dist/blueprints.js.map +1 -0
  14. package/dist/client.d.ts +290 -0
  15. package/dist/client.d.ts.map +1 -0
  16. package/dist/client.js +924 -0
  17. package/dist/client.js.map +1 -0
  18. package/dist/context.d.ts +42 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +198 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/dockerfile.d.ts +36 -0
  23. package/dist/dockerfile.d.ts.map +1 -0
  24. package/dist/dockerfile.js +338 -0
  25. package/dist/dockerfile.js.map +1 -0
  26. package/dist/errors.d.ts +67 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +171 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/image-ref.d.ts +35 -0
  31. package/dist/image-ref.d.ts.map +1 -0
  32. package/dist/image-ref.js +97 -0
  33. package/dist/image-ref.js.map +1 -0
  34. package/dist/index.d.ts +16 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +20 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/mapping.d.ts +49 -0
  39. package/dist/mapping.d.ts.map +1 -0
  40. package/dist/mapping.js +206 -0
  41. package/dist/mapping.js.map +1 -0
  42. package/dist/sdk.d.ts +182 -0
  43. package/dist/sdk.d.ts.map +1 -0
  44. package/dist/sdk.js +356 -0
  45. package/dist/sdk.js.map +1 -0
  46. package/dist/secrets.d.ts +41 -0
  47. package/dist/secrets.d.ts.map +1 -0
  48. package/dist/secrets.js +58 -0
  49. package/dist/secrets.js.map +1 -0
  50. package/dist/shared-types.d.ts +440 -0
  51. package/dist/shared-types.d.ts.map +1 -0
  52. package/dist/shared-types.js +4 -0
  53. package/dist/shared-types.js.map +1 -0
  54. package/dist/types.d.ts +920 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +4 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +48 -4
package/dist/client.js ADDED
@@ -0,0 +1,924 @@
1
+ // REST-layer client: `new Runloop({ bearerToken })` with `client.devboxes.*`, built on
2
+ // @solarisdk/sandbox. Runloop's per-devbox calls (execute, file ops, snapshot) need a
3
+ // live control channel in Solari, so the client keeps one cached handle per devbox.
4
+ import { ConnectionError, GatewayError, HttpTransport, SolariError } from "@solarisdk/core";
5
+ import { SandboxClient } from "@solarisdk/sandbox";
6
+ import * as Errors from "./errors.js";
7
+ import { Blueprints } from "./blueprints.js";
8
+ import { Secrets } from "./secrets.js";
9
+ import { APIConnectionError, APIConnectionTimeoutError, APIError, NotFoundError, RunloopError, SolariCompatUnsupported, translateError, translating, unsupportedNamespace } from "./errors.js";
10
+ import { absPath, DEFAULT_HOME, mapStatus, statusToState, toDevboxView, toSnapshotView, translateCreate, } from "./mapping.js";
11
+ export const DEFAULT_BASE_URL = "https://api.getsolari.com";
12
+ /** @internal Whether an inherited base URL may receive a Solari key: https on getsolari.com or a subdomain, or loopback over http(s). */
13
+ export function isSolariBaseUrl(raw) {
14
+ let u;
15
+ try {
16
+ u = new URL(raw);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ const host = u.hostname.toLowerCase().replace(/\.$/, "");
22
+ if (u.protocol === "https:" && (host === "getsolari.com" || host.endsWith(".getsolari.com")))
23
+ return true;
24
+ const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
25
+ return loopback && (u.protocol === "http:" || u.protocol === "https:");
26
+ }
27
+ function describeHost(raw) {
28
+ try {
29
+ return new URL(raw).hostname || "unparsable URL";
30
+ }
31
+ catch {
32
+ return "unparsable URL";
33
+ }
34
+ }
35
+ const DEFAULT_KEEP_ALIVE_MS = 30 * 60 * 1000;
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
+ export class Runloop {
38
+ // Error classes hang off the class, as in Runloop's client (`Runloop.APIError`).
39
+ static RunloopError = Errors.RunloopError;
40
+ static APIError = Errors.APIError;
41
+ static APIConnectionError = Errors.APIConnectionError;
42
+ static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError;
43
+ static APIUserAbortError = Errors.APIUserAbortError;
44
+ static NotFoundError = Errors.NotFoundError;
45
+ static ConflictError = Errors.ConflictError;
46
+ static RateLimitError = Errors.RateLimitError;
47
+ static BadRequestError = Errors.BadRequestError;
48
+ static AuthenticationError = Errors.AuthenticationError;
49
+ static PermissionDeniedError = Errors.PermissionDeniedError;
50
+ static UnprocessableEntityError = Errors.UnprocessableEntityError;
51
+ static InternalServerError = Errors.InternalServerError;
52
+ static SolariCompatUnsupported = Errors.SolariCompatUnsupported;
53
+ // REST resources Solari has no equivalent for: every call throws SolariCompatUnsupported.
54
+ blueprints;
55
+ /** @internal Build contexts for COPY / ADD, by storage-object id. */
56
+ contexts = new Map();
57
+ objects = unsupportedNamespace("objects");
58
+ secrets;
59
+ agents = unsupportedNamespace("agents");
60
+ axons = unsupportedNamespace("axons");
61
+ networkPolicies = unsupportedNamespace("networkPolicies");
62
+ gatewayConfigs = unsupportedNamespace("gatewayConfigs");
63
+ mcpConfigs = unsupportedNamespace("mcpConfigs");
64
+ bearerToken;
65
+ baseURL;
66
+ devboxes;
67
+ /** @internal */
68
+ solari;
69
+ /** @internal Raw gateway requests for routes SandboxClient has no method for (PATCH, logs, templates, ...). */
70
+ http;
71
+ /** @internal Does GET /sandboxes honour `startingAfter`? Learned from the first list reply (`hasMore` present). */
72
+ startingAfterSupported;
73
+ /** @internal Output of commands run through this client, per devbox, for `devbox.logs()` on gateways without a logs route. */
74
+ localLogs = new Map();
75
+ handles = new Map();
76
+ /** @internal */
77
+ executions = new Map();
78
+ /** @internal */
79
+ keepAliveMs = new Map();
80
+ /** @internal */
81
+ createdAtMs = new Map();
82
+ /** @internal Devboxes this client opened an (open, per-port preview) tunnel for. */
83
+ tunnels = new Set();
84
+ constructor(opts = {}) {
85
+ const env = (k) => (typeof process !== "undefined" ? process.env[k] : undefined) || undefined;
86
+ // Only Solari keys (`slr_…`) are ever used or sent. A Runloop key (for example the RUNLOOP_API_KEY
87
+ // a Runloop-targeting program already has in its environment) must never be sent to Solari, so a
88
+ // non-slr_ candidate is skipped with a warning and the next source is tried.
89
+ const candidates = [
90
+ ["the bearerToken option", opts.bearerToken],
91
+ ["the apiKey option", opts.apiKey],
92
+ ["SOLARI_API_KEY", env("SOLARI_API_KEY")],
93
+ ["RUNLOOP_API_KEY", env("RUNLOOP_API_KEY")],
94
+ ];
95
+ let key;
96
+ for (const [source, value] of candidates) {
97
+ if (!value)
98
+ continue;
99
+ if (value.startsWith("slr_")) {
100
+ key = value;
101
+ break;
102
+ }
103
+ console.warn(`@solarisdk/runloop-compat: ignoring ${source}: it is not a Solari API key (slr_…). It is not sent anywhere.`);
104
+ }
105
+ if (!key) {
106
+ throw new RunloopError("No Solari API key. Set SOLARI_API_KEY, or pass new Runloop({ bearerToken: 'slr_live_…' }). A Runloop key (RUNLOOP_API_KEY) is never sent to Solari and cannot be used here.");
107
+ }
108
+ if (opts.defaultHeaders && Object.keys(opts.defaultHeaders).length > 0) {
109
+ throw new SolariCompatUnsupported("defaultHeaders");
110
+ }
111
+ if (opts.defaultQuery && Object.keys(opts.defaultQuery).length > 0) {
112
+ throw new SolariCompatUnsupported("defaultQuery");
113
+ }
114
+ this.bearerToken = key;
115
+ // RUNLOOP_BASE_URL is what a Runloop-targeting program already has set, usually https://api.runloop.ai.
116
+ // Using it blindly would send the user's slr_ key to Runloop, so it is only honoured when it points at
117
+ // Solari (or loopback, for local gateways). An explicit baseURL option and SOLARI_BASE_URL stay as given.
118
+ const runloopBase = env("RUNLOOP_BASE_URL");
119
+ let inheritedBase;
120
+ if (runloopBase) {
121
+ if (isSolariBaseUrl(runloopBase))
122
+ inheritedBase = runloopBase;
123
+ else if (!opts.baseURL && !env("SOLARI_BASE_URL")) {
124
+ console.warn(`@solarisdk/runloop-compat: ignoring RUNLOOP_BASE_URL (${describeHost(runloopBase)}): it does not point at Solari, and your Solari API key is never sent anywhere else. Using ${DEFAULT_BASE_URL}.`);
125
+ }
126
+ }
127
+ this.baseURL = (opts.baseURL ?? env("SOLARI_BASE_URL") ?? inheritedBase ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
128
+ this.solari = new SandboxClient({
129
+ apiKey: key,
130
+ baseUrl: this.baseURL,
131
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
132
+ ...(opts.timeout ? { callTimeoutMs: opts.timeout } : {}),
133
+ });
134
+ this.http = new HttpTransport({ apiKey: key, baseUrl: this.baseURL, ...(opts.fetch ? { fetch: opts.fetch } : {}) });
135
+ this.devboxes = new Devboxes(this);
136
+ this.blueprints = new Blueprints(this);
137
+ this.secrets = new Secrets(this);
138
+ }
139
+ /** @internal Live control handle for a devbox, cached per id. */
140
+ handle(id) {
141
+ let h = this.handles.get(id);
142
+ if (!h) {
143
+ h = this.solari.connect(id).then(async (sb) => {
144
+ await sb.connect();
145
+ return sb;
146
+ });
147
+ h.catch(() => this.handles.delete(id));
148
+ this.handles.set(id, h);
149
+ }
150
+ return h;
151
+ }
152
+ /**
153
+ * @internal Run `fn` on the devbox's live handle. If the control channel was closed under us
154
+ * (typically the first call after a resume), drop the handle, reconnect and retry once.
155
+ */
156
+ async on(id, fn) {
157
+ for (let attempt = 0;; attempt++) {
158
+ try {
159
+ return await fn(await this.handle(id));
160
+ }
161
+ catch (e) {
162
+ const closed = e instanceof SolariError && !(e instanceof GatewayError) && (e instanceof ConnectionError || /closed|not connected|disconnect|websocket/i.test(e.message));
163
+ if (!closed || attempt >= 2)
164
+ throw translateError(e);
165
+ this.dropHandle(id);
166
+ await sleep(500 * (attempt + 1));
167
+ }
168
+ }
169
+ }
170
+ homes = new Map();
171
+ /** @internal The guest's $HOME, created if missing (Runloop runs commands from the user's home). */
172
+ home(id) {
173
+ let h = this.homes.get(id);
174
+ if (!h) {
175
+ h = (async () => {
176
+ const r = await this.on(id, (sb) => sb.commands.run("/bin/bash", { args: ["-lc", 'printf %s "$HOME"'] }));
177
+ const home = r.stdout.trim() || DEFAULT_HOME;
178
+ await this.on(id, (sb) => sb.commands.run("/bin/mkdir", { args: ["-p", home] }));
179
+ return home;
180
+ })();
181
+ h.catch(() => this.homes.delete(id));
182
+ this.homes.set(id, h);
183
+ }
184
+ return h;
185
+ }
186
+ /** @internal */
187
+ dropHandle(id) {
188
+ const h = this.handles.get(id);
189
+ this.handles.delete(id);
190
+ h?.then((s) => s.close()).catch(() => undefined);
191
+ }
192
+ /** @internal */
193
+ adopt(id, sb) {
194
+ this.handles.set(id, sb.connect().then(() => sb));
195
+ this.handles.get(id)?.catch(() => this.handles.delete(id));
196
+ }
197
+ }
198
+ export class DevboxesCursorIDPage {
199
+ nextCursor;
200
+ fetchNext;
201
+ devboxes;
202
+ has_more;
203
+ total_count;
204
+ /** @internal */
205
+ constructor(items, nextCursor, fetchNext) {
206
+ this.nextCursor = nextCursor;
207
+ this.fetchNext = fetchNext;
208
+ this.devboxes = items;
209
+ this.has_more = !!nextCursor;
210
+ // Solari does not report a total; this is the count known so far.
211
+ this.total_count = items.length;
212
+ }
213
+ getPaginatedItems() {
214
+ return this.devboxes;
215
+ }
216
+ hasNextPage() {
217
+ return this.has_more;
218
+ }
219
+ async getNextPage() {
220
+ if (!this.nextCursor)
221
+ throw new RunloopError("No next page expected; please check `.hasNextPage()` before calling `.getNextPage()`.");
222
+ return this.fetchNext(this.nextCursor);
223
+ }
224
+ async *iterPages() {
225
+ let page = this;
226
+ yield page;
227
+ while (page.hasNextPage()) {
228
+ page = await page.getNextPage();
229
+ yield page;
230
+ }
231
+ }
232
+ async *[Symbol.asyncIterator]() {
233
+ for await (const p of this.iterPages())
234
+ for (const i of p.getPaginatedItems())
235
+ yield i;
236
+ }
237
+ }
238
+ function lines(buf, chunk, cb) {
239
+ if (!cb)
240
+ return;
241
+ buf.s += chunk;
242
+ let i;
243
+ while ((i = buf.s.indexOf("\n")) >= 0) {
244
+ cb(buf.s.slice(0, i + 1));
245
+ buf.s = buf.s.slice(i + 1);
246
+ }
247
+ }
248
+ let execSeq = 0;
249
+ export class Devboxes {
250
+ client;
251
+ /** Disk snapshots (`client.devboxes.diskSnapshots`). */
252
+ diskSnapshots;
253
+ constructor(client) {
254
+ this.client = client;
255
+ this.diskSnapshots = new DiskSnapshots(client);
256
+ }
257
+ /** `POST /v1/devboxes` equivalent: create a sandbox. Returns once it is running. */
258
+ async create(params = {}, _options) {
259
+ // blueprint_id / blueprint_name are Solari templates: custom names resolve to the newest matching `tpl_` id.
260
+ const bp = params.blueprint_id ?? params.blueprint_name ?? null;
261
+ const resolved = bp ? await this.client.blueprints.resolveTemplate(bp) : null;
262
+ const tunnel = params.tunnel;
263
+ if (tunnel)
264
+ assertTunnelOptions(tunnel);
265
+ const t = translateCreate({ ...params, blueprint_id: null, tunnel: null, ...(resolved ? { blueprint_name: resolved } : {}) });
266
+ return this.createFrom(t, params);
267
+ }
268
+ async createAndAwaitRunning(params = {}, options) {
269
+ const v = await this.create(params, options);
270
+ return this.awaitRunning(v.id, options);
271
+ }
272
+ async createFrom(t, params) {
273
+ const c = this.client;
274
+ // A template that just finished building can read as still "building" on another gateway replica for up to the
275
+ // registry's 15 s cache TTL: retry `TemplateNotReady` for up to ~40 s instead of failing a create that is about to work.
276
+ let sb;
277
+ for (let attempt = 0; sb === undefined; attempt++) {
278
+ try {
279
+ sb = await c.solari.create(t.opts);
280
+ }
281
+ catch (e) {
282
+ if (e instanceof GatewayError && e.code === "TemplateNotReady" && attempt < 20)
283
+ await sleep(2000);
284
+ else
285
+ throw translateError(e);
286
+ }
287
+ }
288
+ const id = sb.sandboxId;
289
+ c.adopt(id, sb);
290
+ c.createdAtMs.set(id, Date.now());
291
+ c.keepAliveMs.set(id, t.opts.idleTimeoutMs ?? DEFAULT_KEEP_ALIVE_MS);
292
+ try {
293
+ if (Object.keys(t.fileMounts).length || t.launchCommands.length || t.entrypoint) {
294
+ const home = await c.home(id);
295
+ for (const [path, contents] of Object.entries(t.fileMounts))
296
+ await this.writeAbs(id, absPath(home, path), contents);
297
+ for (const cmd of t.launchCommands) {
298
+ const r = await c.on(id, (h) => h.commands.run("/bin/bash", { args: ["-lc", cmd], cwd: home }));
299
+ if (r.exitCode !== 0) {
300
+ throw new APIError(500, { message: `launch command failed (exit ${r.exitCode}): ${cmd}\n${r.stderr}` }, undefined, {});
301
+ }
302
+ }
303
+ if (t.entrypoint)
304
+ await c.on(id, (h) => h.commands.start("/bin/bash", { args: ["-lc", t.entrypoint], cwd: home }));
305
+ }
306
+ if (params.tunnel)
307
+ await this.enableTunnel(id, params.tunnel);
308
+ }
309
+ catch (e) {
310
+ await c.solari.kill(id).catch(() => undefined);
311
+ c.dropHandle(id);
312
+ throw e;
313
+ }
314
+ return this.retrieveFresh(id);
315
+ }
316
+ /** GET right after create can 404 on another gateway replica; retry briefly (0.3 s * n). */
317
+ async retrieveFresh(id) {
318
+ for (let n = 1;; n++) {
319
+ try {
320
+ return await this.retrieve(id);
321
+ }
322
+ catch (e) {
323
+ if (!(e instanceof NotFoundError) || n > 6)
324
+ throw e;
325
+ await sleep(300 * n);
326
+ }
327
+ }
328
+ }
329
+ async retrieve(id, _options) {
330
+ const v = await translating(() => this.client.solari.get(id));
331
+ const created = this.client.createdAtMs.get(id);
332
+ if (created === undefined && v.createdAt === undefined) {
333
+ this.client.createdAtMs.set(id, Date.now());
334
+ }
335
+ const ka = this.client.keepAliveMs.get(id);
336
+ return toDevboxView(v, { createdAtMs: this.client.createdAtMs.get(id) ?? Date.now(), ...(ka ? { keepAliveSeconds: Math.floor(ka / 1000) } : {}) });
337
+ }
338
+ /** Does the gateway take `startingAfter=<sandboxId>` on GET /sandboxes? Probed once (it also adds `hasMore`). */
339
+ async supportsStartingAfter() {
340
+ if (this.client.startingAfterSupported === undefined) {
341
+ const probe = await translating(() => this.client.http.request("GET", "/sandboxes?kind=sandbox&limit=1"));
342
+ this.client.startingAfterSupported = "hasMore" in probe;
343
+ }
344
+ return this.client.startingAfterSupported;
345
+ }
346
+ /**
347
+ * List devboxes (sandboxes only). `starting_after` is an item id: a gateway with `startingAfter` support takes
348
+ * it directly; on an older one the client scans from the start and skips past it.
349
+ */
350
+ async list(params = {}, _options) {
351
+ const state = params.status ? statusToState(params.status) : undefined;
352
+ if (params.status && !state)
353
+ throw new SolariCompatUnsupported(`list status=${params.status}`);
354
+ const limit = params.limit ?? 20;
355
+ const native = params.starting_after ? await this.supportsStartingAfter() : false;
356
+ const fetchPage = async (cursor, startingAfter, skipUntil) => {
357
+ let cur = cursor;
358
+ let skipping = skipUntil;
359
+ for (;;) {
360
+ const q = new URLSearchParams({ kind: "sandbox", limit: String(limit) });
361
+ if (state)
362
+ q.set("state", state);
363
+ if (cur)
364
+ q.set("cursor", cur);
365
+ if (startingAfter && !cur)
366
+ q.set("startingAfter", startingAfter);
367
+ if (params.include_total_count)
368
+ q.set("includeTotalCount", "true");
369
+ const r = await translating(() => this.client.http.request("GET", `/sandboxes?${q}`));
370
+ if ("hasMore" in r)
371
+ this.client.startingAfterSupported = true;
372
+ let items = r.sandboxes;
373
+ if (skipping) {
374
+ const i = items.findIndex((s) => s.sandboxId === skipping);
375
+ if (i >= 0) {
376
+ items = items.slice(i + 1);
377
+ skipping = undefined;
378
+ }
379
+ else if (r.nextCursor) {
380
+ cur = r.nextCursor;
381
+ continue;
382
+ }
383
+ else {
384
+ items = [];
385
+ skipping = undefined;
386
+ }
387
+ }
388
+ const views = items
389
+ .filter((s) => !params.status || mapStatus(s.state) === params.status || (params.status === "running" && s.state === "running"))
390
+ .map((s) => toDevboxView(s, { createdAtMs: this.client.createdAtMs.get(s.sandboxId) ?? Date.now() }));
391
+ const page = new DevboxesCursorIDPage(views, r.nextCursor, (next) => fetchPage(next, undefined, undefined));
392
+ if (typeof r.totalCount === "number")
393
+ page.total_count = r.totalCount;
394
+ if (typeof r.hasMore === "boolean")
395
+ page.has_more = r.hasMore;
396
+ return page;
397
+ }
398
+ };
399
+ return fetchPage(undefined, native ? params.starting_after : undefined, native ? undefined : params.starting_after);
400
+ }
401
+ /**
402
+ * Update name / metadata (`PATCH /sandboxes/{id}`, pinetree-desktop#566). A gateway without the route
403
+ * answers 404; since a real missing devbox is also a 404, the devbox is re-read to tell them apart.
404
+ */
405
+ async update(id, params, _options) {
406
+ const body = {};
407
+ if (params.name !== undefined)
408
+ body.name = params.name;
409
+ if (params.metadata !== undefined)
410
+ body.metadata = params.metadata;
411
+ try {
412
+ await this.client.http.request("PATCH", `/sandboxes/${encodeURIComponent(id)}`, body);
413
+ }
414
+ catch (e) {
415
+ if (e instanceof GatewayError && (e.status === 404 || e.status === 405)) {
416
+ await this.retrieve(id); // a real missing devbox raises NotFoundError here
417
+ throw new SolariCompatUnsupported("devboxes.update", "This gateway has no PATCH /sandboxes/{id} yet");
418
+ }
419
+ throw translateError(e);
420
+ }
421
+ return this.retrieve(id);
422
+ }
423
+ /**
424
+ * Devbox logs. Uses the gateway's `GET /sandboxes/{id}/logs` when it has one; otherwise returns the output of the
425
+ * commands run through THIS client instance (they are not durable across processes).
426
+ */
427
+ async logs(id, params = {}, _options) {
428
+ const q = new URLSearchParams();
429
+ if (params.execution_id)
430
+ q.set("executionId", params.execution_id);
431
+ try {
432
+ const d = await this.client.http.request("GET", `/sandboxes/${encodeURIComponent(id)}/logs${q.toString() ? `?${q}` : ""}`);
433
+ const logs = (d.logs ?? []).map((e) => ({
434
+ level: e.source === "stderr" ? "ERROR" : "INFO",
435
+ source: (e.source === "entrypoint" ? "entrypoint" : "exec"),
436
+ timestamp_ms: typeof e.ts === "number" ? e.ts : Date.parse(String(e.ts ?? "")) || Date.now(),
437
+ message: e.message ?? "",
438
+ ...(e.executionId ? { cmd_id: e.executionId } : {}),
439
+ }));
440
+ return { logs: params.text ? logs.filter((l) => (l.message ?? "").includes(params.text)) : logs };
441
+ }
442
+ catch (e) {
443
+ if (!(e instanceof GatewayError && (e.status === 404 || e.status === 405)))
444
+ throw translateError(e);
445
+ await this.retrieve(id); // a real missing devbox raises NotFoundError here
446
+ if (params.shell_name)
447
+ return { logs: [] };
448
+ const rows = (this.client.localLogs.get(id) ?? []).filter((r) => !params.execution_id || r.execId === params.execution_id);
449
+ const logs = rows
450
+ .filter((r) => !params.text || r.message.includes(params.text))
451
+ .map((r) => ({
452
+ level: r.stream === "stderr" ? "ERROR" : "INFO",
453
+ source: "exec",
454
+ timestamp_ms: r.ts,
455
+ cmd: r.cmd,
456
+ cmd_id: r.execId,
457
+ ...(r.stream === "exit" ? { exit_code: r.exitCode ?? null } : { message: r.message }),
458
+ }));
459
+ return { logs };
460
+ }
461
+ }
462
+ async shutdown(id, _params, _options) {
463
+ let before;
464
+ try {
465
+ before = await this.retrieve(id);
466
+ }
467
+ catch {
468
+ /* already gone: kill is idempotent */
469
+ }
470
+ await translating(() => this.client.solari.kill(id));
471
+ this.client.dropHandle(id);
472
+ this.client.keepAliveMs.delete(id);
473
+ const now = Date.now();
474
+ const base = before ?? { id, create_time_ms: now, metadata: {}, launch_parameters: {}, capabilities: [], state_transitions: [] };
475
+ return {
476
+ ...base,
477
+ status: "shutdown",
478
+ end_time_ms: now,
479
+ state_transitions: [...base.state_transitions, { status: "shutdown", transition_time_ms: now }],
480
+ };
481
+ }
482
+ async suspend(id, _options) {
483
+ await this.client.on(id, (sb) => sb.pause());
484
+ return this.retrieve(id);
485
+ }
486
+ /** Resume a suspended devbox and wait until it is running. */
487
+ async resume(id, options) {
488
+ await this.resumeAsync(id, options);
489
+ let v = await this.awaitRunning(id, options);
490
+ // The gateway's view can flap running/paused for a second or two right after a resume
491
+ // (observed on staging), so require a short run of consecutive "running" reads.
492
+ for (let ok = 1, tries = 0; ok < 3 && tries < 40; tries++) {
493
+ await sleep(300);
494
+ v = await this.retrieve(id);
495
+ ok = v.status === "running" ? ok + 1 : 1;
496
+ }
497
+ return v;
498
+ }
499
+ async resumeAsync(id, _options) {
500
+ const sb = await this.client.handle(id);
501
+ try {
502
+ await translating(() => sb.resume());
503
+ }
504
+ catch (e) {
505
+ // The resume itself succeeded server-side if only the channel reconnect failed: reconnect fresh.
506
+ if (!(e instanceof APIConnectionError))
507
+ throw e;
508
+ this.client.dropHandle(id);
509
+ await this.client.on(id, async () => undefined);
510
+ }
511
+ return this.retrieve(id);
512
+ }
513
+ /** Extend the idle window by re-arming the timeout. */
514
+ async keepAlive(id, _options) {
515
+ const ms = this.client.keepAliveMs.get(id) ?? DEFAULT_KEEP_ALIVE_MS;
516
+ await this.client.on(id, (sb) => sb.setTimeout(ms));
517
+ return {};
518
+ }
519
+ awaitRunning(id, options) {
520
+ return this.poll(id, "running", options);
521
+ }
522
+ awaitSuspended(id, options) {
523
+ return this.poll(id, "suspended", options);
524
+ }
525
+ async poll(id, want, options) {
526
+ const interval = options?.polling?.pollingIntervalMs ?? 1000;
527
+ const maxAttempts = options?.polling?.maxAttempts ?? 120;
528
+ const deadline = options?.polling?.timeoutMs ? Date.now() + options.polling.timeoutMs : Infinity;
529
+ for (let i = 0; i < maxAttempts; i++) {
530
+ const v = await this.retrieve(id);
531
+ if (v.status === want)
532
+ return v;
533
+ if (v.status === "failure" || v.status === "shutdown") {
534
+ throw new APIError(409, { message: `Devbox ${id} is ${v.status}, expected ${want}` }, undefined, {});
535
+ }
536
+ if (Date.now() > deadline)
537
+ break;
538
+ await sleep(interval);
539
+ }
540
+ throw new APIConnectionTimeoutError({ message: `Timed out waiting for devbox ${id} to be ${want}` });
541
+ }
542
+ // --- execution ----------------------------------------------------------
543
+ /** Start a shell command and track it. `callbacks` receive complete lines. */
544
+ async startExecution(id, command, callbacks = {}) {
545
+ if (command.trim() === "")
546
+ throw new APIError(400, { message: "command must not be empty" }, undefined, {});
547
+ const home = await this.client.home(id);
548
+ const handle = await this.client.on(id, (sb) => sb.commands.start("/bin/bash", { args: ["-lc", command], cwd: home }));
549
+ const exec = {
550
+ executionId: `exec_${handle.cmdId}_${++execSeq}`,
551
+ devboxId: id,
552
+ status: "running",
553
+ exitStatus: null,
554
+ stdout: "",
555
+ stderr: "",
556
+ handle,
557
+ done: Promise.resolve(),
558
+ };
559
+ const ob = { s: "" };
560
+ const eb = { s: "" };
561
+ const record = (stream, message, exitCode) => {
562
+ const arr = this.client.localLogs.get(id) ?? [];
563
+ arr.push({ ts: Date.now(), cmd: command, execId: exec.executionId, stream, message, ...(exitCode !== undefined ? { exitCode } : {}) });
564
+ if (arr.length > 2000)
565
+ arr.splice(0, arr.length - 2000);
566
+ this.client.localLogs.set(id, arr);
567
+ };
568
+ handle.onData((c) => {
569
+ record(c.stream === "stderr" ? "stderr" : "stdout", c.data);
570
+ if (c.stream === "stdout") {
571
+ exec.stdout += c.data;
572
+ lines(ob, c.data, callbacks.stdout);
573
+ lines({ s: "" }, c.data, callbacks.output);
574
+ }
575
+ else {
576
+ exec.stderr += c.data;
577
+ lines(eb, c.data, callbacks.stderr);
578
+ lines({ s: "" }, c.data, callbacks.output);
579
+ }
580
+ });
581
+ exec.done = handle.wait().then((code) => {
582
+ exec.exitStatus = code;
583
+ exec.status = "completed";
584
+ record("exit", "", code);
585
+ if (ob.s)
586
+ callbacks.stdout?.(ob.s);
587
+ if (eb.s)
588
+ callbacks.stderr?.(eb.s);
589
+ }, (e) => {
590
+ exec.status = "completed";
591
+ exec.exitStatus = exec.exitStatus ?? -1;
592
+ throw translatingError(e);
593
+ });
594
+ exec.done.catch(() => undefined);
595
+ this.client.executions.set(exec.executionId, exec);
596
+ return exec;
597
+ }
598
+ view(rec) {
599
+ return {
600
+ devbox_id: rec.devboxId,
601
+ execution_id: rec.executionId,
602
+ status: rec.status,
603
+ exit_status: rec.exitStatus,
604
+ stdout: rec.stdout,
605
+ stderr: rec.stderr,
606
+ stdout_truncated: false,
607
+ stderr_truncated: false,
608
+ };
609
+ }
610
+ /** Run to completion and return the final detail view (`execute_and_await_completion`). */
611
+ async executeAndAwaitCompletion(id, params, _options) {
612
+ if (params.shell_name)
613
+ throw new SolariCompatUnsupported("named shells (shell_name)");
614
+ const rec = await this.startExecution(id, params.command);
615
+ await rec.done;
616
+ return this.view(rec);
617
+ }
618
+ async executeSync(id, params, options) {
619
+ if (params.attach_stdin)
620
+ throw new SolariCompatUnsupported("attach_stdin on a synchronous execute");
621
+ const v = await this.executeAndAwaitCompletion(id, { command: params.command, shell_name: params.shell_name ?? null }, options);
622
+ return { devbox_id: id, exit_status: v.exit_status ?? 0, stdout: v.stdout ?? "", stderr: v.stderr ?? "" };
623
+ }
624
+ async execute(id, params, options) {
625
+ return this.executeAndAwaitCompletion(id, params, options);
626
+ }
627
+ async executeAsync(id, params, _options) {
628
+ if (params.shell_name)
629
+ throw new SolariCompatUnsupported("named shells (shell_name)");
630
+ const rec = await this.startExecution(id, params.command);
631
+ return this.view(rec);
632
+ }
633
+ /** REST executions resource (`client.devboxes.executions.*`), in-process registry only. */
634
+ get executions() {
635
+ return new Executions(this);
636
+ }
637
+ async waitForCommand(id, executionId, params = {}, _options) {
638
+ const rec = this.findExecution(id, executionId);
639
+ const want = params.statuses ?? ["completed"];
640
+ const timeoutMs = (params.timeout_seconds ?? 30) * 1000;
641
+ if (want.includes("completed")) {
642
+ await Promise.race([rec.done.catch(() => undefined), sleep(timeoutMs)]);
643
+ }
644
+ return this.view(rec);
645
+ }
646
+ async listDiskSnapshots(params = {}) {
647
+ return this.diskSnapshots.list(params);
648
+ }
649
+ async deleteDiskSnapshot(snapshotId) {
650
+ return this.diskSnapshots.delete(snapshotId);
651
+ }
652
+ /** Executions are tracked per client instance only (Solari has no durable execution records yet). */
653
+ findExecution(id, executionId) {
654
+ const rec = this.client.executions.get(executionId);
655
+ if (!rec || rec.devboxId !== id) {
656
+ throw new NotFoundError(404, { message: `Execution ${executionId} is not known to this client; durable executions are not supported` }, undefined, {});
657
+ }
658
+ return rec;
659
+ }
660
+ // --- files ----------------------------------------------------------------
661
+ async readFileContents(id, params, _options) {
662
+ const path = absPath(await this.client.home(id), params.file_path);
663
+ return this.client.on(id, (sb) => sb.files.readText(path));
664
+ }
665
+ /** Write a file, creating its parent directories. */
666
+ async writeAbs(id, path, data, upload = false) {
667
+ const parent = path.slice(0, path.lastIndexOf("/")) || "/";
668
+ await this.client.on(id, (sb) => sb.commands.run("/bin/mkdir", { args: ["-p", parent] }));
669
+ await this.client.on(id, (sb) => (upload ? sb.files.upload(path, data) : sb.files.write(path, data)));
670
+ }
671
+ async writeFileContents(id, params, _options) {
672
+ await this.writeAbs(id, absPath(await this.client.home(id), params.file_path), params.contents);
673
+ return { devbox_id: id, exit_status: 0, stdout: "", stderr: "" };
674
+ }
675
+ async downloadFile(id, params, _options) {
676
+ const path = absPath(await this.client.home(id), params.path);
677
+ const bytes = await this.client.on(id, (sb) => sb.files.download(path));
678
+ return new Response(bytes, { status: 200, headers: { "content-type": "application/octet-stream" } });
679
+ }
680
+ async uploadFile(id, params, _options) {
681
+ const f = params.file;
682
+ let data;
683
+ if (f === undefined)
684
+ throw new APIError(400, { message: "uploadFile requires `file`" }, undefined, {});
685
+ else if (typeof f === "string" || f instanceof Uint8Array)
686
+ data = f;
687
+ else if (f instanceof ArrayBuffer)
688
+ data = new Uint8Array(f);
689
+ else
690
+ data = new Uint8Array(await f.arrayBuffer());
691
+ await this.writeAbs(id, absPath(await this.client.home(id), params.path), data, true);
692
+ return {};
693
+ }
694
+ // --- snapshots / misc ------------------------------------------------------
695
+ async snapshotDisk(id, params = {}, _options) {
696
+ let snapId;
697
+ for (let attempt = 0; snapId === undefined; attempt++) {
698
+ try {
699
+ snapId = await this.client.on(id, (sb) => sb.snapshot(params.name ?? undefined));
700
+ }
701
+ catch (e) {
702
+ // NotRunning / "Not snapshottable" right after a resume is a stale read on another gateway replica
703
+ // (observed on staging: up to ~20 s). Retry for up to a minute before giving up.
704
+ const err = e;
705
+ const transient = err.error?.code === "NotRunning" || /not snapshottable/i.test(err.message ?? "") || /not snapshottable/i.test(err.error?.error ?? "");
706
+ if (transient && attempt < 60)
707
+ await sleep(1000);
708
+ else
709
+ throw e;
710
+ }
711
+ }
712
+ // The snapshot record can briefly 404 on another gateway replica.
713
+ let s;
714
+ for (let n = 1; s === undefined; n++) {
715
+ try {
716
+ s = await this.client.solari.getSnapshot(snapId);
717
+ }
718
+ catch (e) {
719
+ if (!(e instanceof GatewayError && e.status === 404) || n > 6)
720
+ throw translateError(e);
721
+ await sleep(300 * n);
722
+ }
723
+ }
724
+ // commit_message / metadata are echoed on this response but not stored until the gateway supports them.
725
+ return toSnapshotView(s, id, { metadata: params.metadata ?? null, commitMessage: params.commit_message ?? null });
726
+ }
727
+ /** Solari snapshots are synchronous, so the "async" call returns an already-complete snapshot. */
728
+ snapshotDiskAsync(id, params = {}, options) {
729
+ return this.snapshotDisk(id, params, options);
730
+ }
731
+ /**
732
+ * The devbox's tunnel (`GET /sandboxes/:id/tunnel`, re-mints `auth_token`). Rejects with `NotFoundError` when
733
+ * none is enabled. On a gateway without tunnel routes only an open tunnel enabled through this client can be shown.
734
+ */
735
+ async tunnelView(id, port = 8080) {
736
+ try {
737
+ return toTunnelView(await this.client.http.request("GET", `/sandboxes/${encodeURIComponent(id)}/tunnel`));
738
+ }
739
+ catch (e) {
740
+ if (!(e instanceof GatewayError) || e.status !== 404)
741
+ throw translateError(e);
742
+ if (e.body?.code === "TunnelNotEnabled")
743
+ throw translateError(e);
744
+ if (!this.client.tunnels.has(id))
745
+ throw translateError(e);
746
+ }
747
+ // Gateway without tunnel routes: the open per-port preview URL has `<key>-<port>` as its first label.
748
+ const url = new URL(await this.previewUrl(id, port));
749
+ const first = url.hostname.split(".")[0] ?? "";
750
+ const key = first.endsWith(`-${port}`) ? first.slice(0, -`-${port}`.length) : first;
751
+ return { auth_mode: "open", create_time_ms: Date.now(), http_keep_alive: false, tunnel_key: key, wake_on_http: false };
752
+ }
753
+ /** `getTunnel()`: the tunnel, or `null` when none is enabled. */
754
+ async tunnelOrNull(id) {
755
+ try {
756
+ return await this.tunnelView(id);
757
+ }
758
+ catch (e) {
759
+ if (e instanceof NotFoundError)
760
+ return null;
761
+ throw e;
762
+ }
763
+ }
764
+ /**
765
+ * `POST /sandboxes/:id/tunnel`: authenticated (default) or open, `wake_on_http`, `http_keep_alive`. Idempotent;
766
+ * the returned `auth_token` (authenticated only) is valid on every port for an hour and dies with `removeTunnel`.
767
+ */
768
+ async enableTunnel(id, params = {}, _options) {
769
+ assertTunnelOptions(params);
770
+ const body = {
771
+ authMode: params.auth_mode ?? "authenticated",
772
+ ...(params.wake_on_http != null ? { wakeOnHttp: params.wake_on_http } : {}),
773
+ ...(params.http_keep_alive != null ? { keepAlive: params.http_keep_alive } : {}),
774
+ };
775
+ try {
776
+ return toTunnelView(await this.client.http.request("POST", `/sandboxes/${encodeURIComponent(id)}/tunnel`, body));
777
+ }
778
+ catch (e) {
779
+ // A gateway without tunnel routes (404/405): only the open per-port preview URL exists.
780
+ const absent = e instanceof GatewayError && (e.status === 404 || e.status === 405);
781
+ if (!absent || body.authMode !== "open" || body.wakeOnHttp || body.keepAlive)
782
+ throw translateError(e);
783
+ }
784
+ this.client.tunnels.add(id);
785
+ return this.tunnelView(id);
786
+ }
787
+ async previewUrl(id, port) {
788
+ return (await this.client.on(id, (sb) => sb.previewUrl(port))).url;
789
+ }
790
+ /** `DELETE /sandboxes/:id/tunnel`: idempotent; revokes every `auth_token`. */
791
+ async removeTunnel(id, _options) {
792
+ this.client.tunnels.delete(id);
793
+ await translating(() => this.client.http.request("DELETE", `/sandboxes/${encodeURIComponent(id)}/tunnel`));
794
+ }
795
+ createSSHKey() {
796
+ throw new SolariCompatUnsupported("devboxes.createSSHKey");
797
+ }
798
+ watchEvictions() {
799
+ throw new SolariCompatUnsupported("devboxes.watchEvictions");
800
+ }
801
+ }
802
+ function translatingError(e) {
803
+ return e instanceof Error ? e : new RunloopError(String(e));
804
+ }
805
+ function assertTunnelOptions(t) {
806
+ if (t.auth_mode && t.auth_mode !== "open" && t.auth_mode !== "authenticated")
807
+ throw new SolariCompatUnsupported(`tunnel auth_mode=${t.auth_mode}`, "use 'open' or 'authenticated'");
808
+ if (t.auth_mode === "open" && t.wake_on_http)
809
+ throw new SolariCompatUnsupported("tunnel wake_on_http with auth_mode=open", "wake only on authenticated tunnels");
810
+ }
811
+ /** `https://<label>-{port}.<domain>` -> the `<label>` (Runloop's `tunnel_key`). */
812
+ function toTunnelView(w) {
813
+ const first = (w.urlTemplate.replace(/^https?:\/\//, "").split(".")[0] ?? "");
814
+ const key = first.endsWith("-{port}") ? first.slice(0, -"-{port}".length) : first;
815
+ return {
816
+ auth_mode: w.authMode,
817
+ create_time_ms: Date.now(),
818
+ http_keep_alive: !!w.keepAlive,
819
+ tunnel_key: key,
820
+ wake_on_http: !!w.wakeOnHttp,
821
+ ...(w.authToken ? { auth_token: w.authToken } : {}),
822
+ };
823
+ }
824
+ export class Executions {
825
+ dev;
826
+ constructor(dev) {
827
+ this.dev = dev;
828
+ }
829
+ retrieve(devboxId, executionId, _params, _options) {
830
+ return Promise.resolve(this.dev.view(this.dev.findExecution(devboxId, executionId)));
831
+ }
832
+ awaitCompleted(devboxId, executionId, _options) {
833
+ return this.dev.waitForCommand(devboxId, executionId, { statuses: ["completed"], timeout_seconds: 3600 });
834
+ }
835
+ executeAsync(devboxId, params, options) {
836
+ return this.dev.executeAsync(devboxId, params, options);
837
+ }
838
+ executeSync(devboxId, params, options) {
839
+ return this.dev.executeSync(devboxId, params, options);
840
+ }
841
+ async kill(devboxId, executionId, _params, _options) {
842
+ const rec = this.dev.findExecution(devboxId, executionId);
843
+ await rec.handle?.kill();
844
+ return this.dev.view(rec);
845
+ }
846
+ async sendStdIn(devboxId, executionId, params, _options) {
847
+ const rec = this.dev.findExecution(devboxId, executionId);
848
+ if (params.text != null)
849
+ await rec.handle?.stdin(params.text);
850
+ return this.dev.view(rec);
851
+ }
852
+ }
853
+ export class DiskSnapshots {
854
+ client;
855
+ constructor(client) {
856
+ this.client = client;
857
+ }
858
+ async list(params = {}) {
859
+ const r = await translating(() => this.client.solari.listSnapshots({ kind: "sandbox", ...(params.limit ? { limit: params.limit } : {}) }));
860
+ let snaps = r.snapshots;
861
+ if (params.starting_after) {
862
+ const i = snaps.findIndex((s) => s.id === params.starting_after);
863
+ snaps = i >= 0 ? snaps.slice(i + 1) : [];
864
+ }
865
+ const views = snaps.map((s) => toSnapshotView(s));
866
+ return { snapshots: views, has_more: false, total_count: views.length };
867
+ }
868
+ async retrieve(id) {
869
+ return toSnapshotView(await translating(() => this.client.solari.getSnapshot(id)));
870
+ }
871
+ /**
872
+ * `DELETE /snapshots/{id}`. Large snapshots are erased incrementally: the gateway answers `202 {ok:false,
873
+ * status:"deleting"}`, keeps the row and expects the same DELETE again. Runloop's delete is "done when it returns",
874
+ * so this repeats the call (backoff 1 s doubling to 5 s) until the gateway says ok, or a 404 after a 202 says the
875
+ * previous pass finished, for at most `maxWaitMs` (default 5 min). It then raises rather than claim success.
876
+ */
877
+ async delete(id, opts = {}) {
878
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
879
+ const now = opts.now ?? Date.now;
880
+ const maxWaitMs = opts.maxWaitMs ?? 5 * 60_000;
881
+ const started = now();
882
+ let delay = 1000;
883
+ for (let attempt = 0;; attempt++) {
884
+ let r;
885
+ try {
886
+ r = await this.client.http.request("DELETE", `/snapshots/${encodeURIComponent(id)}`);
887
+ }
888
+ catch (e) {
889
+ if (attempt > 0 && e instanceof GatewayError && e.status === 404)
890
+ return {};
891
+ throw translateError(e);
892
+ }
893
+ if (!(r && r.ok === false && r.status === "deleting"))
894
+ return {};
895
+ if (now() - started + delay > maxWaitMs) {
896
+ throw new APIError(504, { message: `Snapshot ${id} is still being deleted after ${Math.round((now() - started) / 1000)} s; call delete again to continue` }, undefined, {});
897
+ }
898
+ await sleep(delay);
899
+ delay = Math.min(delay * 2, 5000);
900
+ }
901
+ }
902
+ /** `PATCH /snapshots/{id}` (pinetree-desktop#566); unsupported on a gateway without it. */
903
+ async update(id, params = {}) {
904
+ const body = {};
905
+ if (params.name !== undefined)
906
+ body.name = params.name;
907
+ if (params.commit_message !== undefined)
908
+ body.commitMessage = params.commit_message;
909
+ if (params.metadata !== undefined)
910
+ body.metadata = params.metadata;
911
+ try {
912
+ await this.client.http.request("PATCH", `/snapshots/${encodeURIComponent(id)}`, body);
913
+ }
914
+ catch (e) {
915
+ if (e instanceof GatewayError && (e.status === 404 || e.status === 405)) {
916
+ await this.retrieve(id); // a real missing snapshot raises NotFoundError here
917
+ throw new SolariCompatUnsupported("snapshot.update", "This gateway has no PATCH /snapshots/{id} yet");
918
+ }
919
+ throw translateError(e);
920
+ }
921
+ return this.retrieve(id);
922
+ }
923
+ }
924
+ //# sourceMappingURL=client.js.map