@solarisdk/core 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ npm install @solarisdk/sdk # unified client + `solari` CLI
18
18
 
19
19
  ## Requirements
20
20
 
21
- Node 18+ (or a browser with `WebSocket` and `fetch`). The type declarations need TypeScript 5.0 or newer; `await using` needs TypeScript 5.2+ and a runtime with `Symbol.asyncDispose` (Node 24, or a polyfill).
21
+ Node 18+ (or a browser with `WebSocket` and `fetch`). The type declarations need TypeScript 5.0 or newer; `await using` works in compiled TypeScript 5.2+ on Node 18.18+ (which provide `Symbol.asyncDispose`); running the syntax uncompiled needs Node 24.
22
22
 
23
23
  ## License
24
24
 
package/dist/errors.d.ts CHANGED
@@ -10,6 +10,12 @@ export declare class GatewayError extends SolariError {
10
10
  readonly body?: GatewayErrorBody;
11
11
  /** The gateway's hint that retrying may help (`body.retryable`), on every gateway error. */
12
12
  readonly retryable?: boolean;
13
+ /** The gateway's request id (`x-request-id`); include it when reporting a problem. */
14
+ readonly requestId?: string;
15
+ /** On a `429`: `"rate_limited"` (transient, honour {@link retryAfterMs}) or `"quota_exceeded"` (a cap; do not retry). */
16
+ readonly errorType?: "rate_limited" | "quota_exceeded";
17
+ /** How long the gateway asked callers to wait (`Retry-After`), in ms; only set on retryable errors. */
18
+ readonly retryAfterMs?: number;
13
19
  constructor(status: number, message: string, body?: GatewayErrorBody);
14
20
  }
15
21
  /** `404` — the sandbox, desktop, snapshot, template or volume does not exist (or is not yours). */
@@ -60,6 +66,20 @@ export declare class SandboxNotStoppedError extends GatewayError {
60
66
  readonly state?: string;
61
67
  constructor(message?: string, body?: GatewayErrorBody);
62
68
  }
69
+ /**
70
+ * `409 {code:"SnapshotNotRestorable"}` — a restore (create from a snapshot, resume of a paused sandbox, revert)
71
+ * the platform knows cannot succeed, so it is refused at once instead of timing out as a capacity error.
72
+ * Never retryable (`retryable === false`). `reason` says why when known: `"golden_mismatch"` means the snapshot
73
+ * was taken on a different template build (`golden.imageKey`) than the fleet runs, and `snapshotImageKey` /
74
+ * `hostImageKey` name the two. Take a fresh snapshot from a new sandbox instead.
75
+ */
76
+ export declare class SnapshotNotRestorableError extends GatewayError {
77
+ /** Why it cannot be restored, when the gateway says (`"golden_mismatch"`). */
78
+ readonly reason?: string;
79
+ readonly snapshotImageKey?: string;
80
+ readonly hostImageKey?: string;
81
+ constructor(message?: string, body?: GatewayErrorBody);
82
+ }
63
83
  /** A computer-use RPC returned `{ok:false}`. */
64
84
  export declare class ActionError extends SolariError {
65
85
  readonly method: string;
package/dist/errors.js CHANGED
@@ -9,12 +9,20 @@ export class GatewayError extends SolariError {
9
9
  code;
10
10
  body;
11
11
  retryable;
12
+ requestId;
13
+ errorType;
14
+ retryAfterMs;
12
15
  constructor(status, message, body) {
13
16
  super(message);
14
17
  this.status = status;
15
18
  this.code = body?.code;
16
19
  this.body = body;
17
20
  this.retryable = body?.retryable;
21
+ this.requestId = body?.requestId;
22
+ this.errorType = body?.errorType;
23
+ if (body?.retryable === true && typeof body.retryAfterSeconds === "number" && body.retryAfterSeconds >= 0) {
24
+ this.retryAfterMs = Math.round(body.retryAfterSeconds * 1000);
25
+ }
18
26
  }
19
27
  }
20
28
  export class NotFoundError extends GatewayError {
@@ -54,6 +62,17 @@ export class SandboxNotStoppedError extends GatewayError {
54
62
  this.state = body?.state;
55
63
  }
56
64
  }
65
+ export class SnapshotNotRestorableError extends GatewayError {
66
+ reason;
67
+ snapshotImageKey;
68
+ hostImageKey;
69
+ constructor(message = "Snapshot cannot be restored", body) {
70
+ super(409, message, body);
71
+ this.reason = body?.reason;
72
+ this.snapshotImageKey = body?.snapshotImageKey;
73
+ this.hostImageKey = body?.hostImageKey;
74
+ }
75
+ }
57
76
  export class ActionError extends SolariError {
58
77
  method;
59
78
  code;
@@ -101,6 +120,8 @@ export function mapGatewayError(status, body) {
101
120
  if (code === "NotStopped" || code === "StartInProgress") {
102
121
  return new SandboxNotStoppedError(msg, body);
103
122
  }
123
+ if (code === "SnapshotNotRestorable")
124
+ return new SnapshotNotRestorableError(msg, body);
104
125
  return new GatewayError(409, msg ?? "Conflict", body);
105
126
  case 503:
106
127
  if (code === undefined || code === "NoCapacity" || code === "StartTimeout")
package/dist/http.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface HttpTransportOptions {
5
5
  fetch?: typeof fetch;
6
6
  /** Max retry attempts for idempotent requests. Default 5. */
7
7
  maxRetries?: number;
8
+ /** Longest server-requested `Retry-After` the transport will wait out (default 30 000 ms); a longer one surfaces the error. */
9
+ maxRetryAfterMs?: number;
8
10
  /** Per-request timeout in ms (AbortSignal). Default 300000. */
9
11
  requestTimeoutMs?: number;
10
12
  /** Fixed retry delay in ms. When unset, uses exponential backoff + jitter.
@@ -24,6 +26,7 @@ export declare class HttpTransport {
24
26
  readonly baseUrl: string;
25
27
  private readonly fetchImpl;
26
28
  private readonly maxRetries;
29
+ private readonly maxRetryAfterMs;
27
30
  private readonly requestTimeoutMs;
28
31
  private readonly retryDelayMs;
29
32
  constructor(o: HttpTransportOptions);
@@ -34,3 +37,5 @@ export declare class HttpTransport {
34
37
  request<T>(method: string, path: string, body?: unknown, opts?: HttpRequestOptions): Promise<T>;
35
38
  private backoffMs;
36
39
  }
40
+ /** `Retry-After` is delta-seconds or an HTTP date; anything else (or negative) is ignored. */
41
+ export declare function parseRetryAfterSeconds(value: string | null): number | undefined;
package/dist/http.js CHANGED
@@ -10,6 +10,7 @@ export class HttpTransport {
10
10
  baseUrl;
11
11
  fetchImpl;
12
12
  maxRetries;
13
+ maxRetryAfterMs;
13
14
  requestTimeoutMs;
14
15
  retryDelayMs;
15
16
  constructor(o) {
@@ -25,6 +26,7 @@ export class HttpTransport {
25
26
  }
26
27
  this.fetchImpl = f;
27
28
  this.maxRetries = o.maxRetries ?? 5;
29
+ this.maxRetryAfterMs = o.maxRetryAfterMs ?? 30_000;
28
30
  this.requestTimeoutMs = o.requestTimeoutMs ?? 300_000;
29
31
  this.retryDelayMs = o.retryDelayMs;
30
32
  }
@@ -70,10 +72,21 @@ export class HttpTransport {
70
72
  catch {
71
73
  errBody = undefined;
72
74
  }
75
+ const headerId = res.headers.get("x-request-id");
76
+ const headerRetryAfter = parseRetryAfterSeconds(res.headers.get("retry-after"));
77
+ if (headerId || headerRetryAfter !== undefined) {
78
+ errBody = {
79
+ ...errBody,
80
+ ...(headerId && !errBody?.requestId ? { requestId: headerId } : {}),
81
+ ...(headerRetryAfter !== undefined ? { retryAfterSeconds: headerRetryAfter } : {}),
82
+ };
83
+ }
73
84
  const RETRYABLE_STATUSES = new Set([502, 503, 504, 507]);
74
85
  const retryable = RETRYABLE_STATUSES.has(res.status) || errBody?.retryable === true;
75
- if (idempotent && retryable && attempt < this.maxRetries) {
76
- await sleep(this.backoffMs(attempt++));
86
+ const retryAfterMs = retryable && typeof errBody?.retryAfterSeconds === "number" ? errBody.retryAfterSeconds * 1000 : undefined;
87
+ const tooLong = retryAfterMs !== undefined && retryAfterMs > this.maxRetryAfterMs;
88
+ if (idempotent && retryable && !tooLong && attempt < this.maxRetries) {
89
+ await sleep(Math.max(this.backoffMs(attempt++), retryAfterMs ?? 0));
77
90
  continue;
78
91
  }
79
92
  throw mapGatewayError(res.status, errBody);
@@ -90,3 +103,14 @@ export class HttpTransport {
90
103
  return Math.min(150 * 2 ** attempt, 8000) + Math.floor(Math.random() * 250);
91
104
  }
92
105
  }
106
+ export function parseRetryAfterSeconds(value) {
107
+ if (value === null)
108
+ return undefined;
109
+ const v = value.trim();
110
+ if (/^\d+$/.test(v))
111
+ return Number(v);
112
+ const t = Date.parse(v);
113
+ if (Number.isNaN(t))
114
+ return undefined;
115
+ return Math.max(0, Math.ceil((t - Date.now()) / 1000));
116
+ }
@@ -0,0 +1,7 @@
1
+ import type { IdlePolicyStages } from "./types.js";
2
+ /**
3
+ * Build the string form of an idle policy from stages, e.g. `{ pause: "5m", delete: "1d" }` becomes
4
+ * `"pause@5m,delete@1d"` (the format the gateway echoes back). Stages come out in order: park, hibernate, pause, delete.
5
+ * A number is milliseconds. The gateway validates the result (ordering, bounds, conflicts); this only formats it.
6
+ */
7
+ export declare function formatIdlePolicy(stages: IdlePolicyStages): string;
@@ -0,0 +1,10 @@
1
+ export function formatIdlePolicy(stages) {
2
+ const order = ["park", "hibernate", "pause", "delete"];
3
+ return order
4
+ .filter((k) => stages[k] !== undefined)
5
+ .map((k) => {
6
+ const v = stages[k];
7
+ return `${k}@${typeof v === "number" ? `${v}ms` : v}`;
8
+ })
9
+ .join(",");
10
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
+ export { formatIdlePolicy } from "./idle-policy.js";
3
4
  export type { SessionConfig, SessionHooks } from "./handle.js";
4
5
  export { Desktop } from "./desktop.js";
5
6
  export type { DesktopConfig } from "./desktop.js";
@@ -11,5 +12,5 @@ export type { TemplateClientOptions } from "./template-client.js";
11
12
  export { VolumeClient } from "./volume-client.js";
12
13
  export type { VolumeClientOptions } from "./volume-client.js";
13
14
  export { DEFAULT_BASE_URL, HttpTransport, newIdempotencyKey } from "./http.js";
14
- export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
15
+ export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SnapshotNotRestorableError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
15
16
  export type * from "./types.js";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
+ export { formatIdlePolicy } from "./idle-policy.js";
3
4
  export { Desktop } from "./desktop.js";
4
5
  export { Sandbox } from "./sandbox.js";
5
6
  export { Image } from "./image.js";
6
7
  export { TemplateClient } from "./template-client.js";
7
8
  export { VolumeClient } from "./volume-client.js";
8
9
  export { DEFAULT_BASE_URL, HttpTransport, newIdempotencyKey } from "./http.js";
9
- export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
10
+ export { ActionError, AuthError, ConcurrencyLimitError, ConnectionError, GatewayError, NoCapacityError, NotFoundError, SandboxNotStoppedError, SnapshotNotRestorableError, SolariError, PlanError, TimeoutError, ValidationError, } from "./errors.js";
package/dist/types.d.ts CHANGED
@@ -1,6 +1,36 @@
1
1
  import type { CompiledImage } from "./image.js";
2
2
  /** Session lifecycle status reported by `GET /desktops/:sessionId`. */
3
3
  export type DesktopStatus = "starting" | "ready" | "paused" | "releasing" | "gone";
4
+ /**
5
+ * One stage of an {@link IdlePolicy}. All stages are optional and every offset is measured from the sandbox's LAST
6
+ * ACTIVITY, so the offsets must increase in this order:
7
+ *
8
+ * - `park`: freeze the vCPUs but keep RAM and the slot; the next request wakes it in milliseconds. Parked time is free.
9
+ * - `hibernate`: checkpoint to disk and free the slot (needs a `park` stage; a parked sandbox escalates to it).
10
+ * - `pause`: snapshot and free the slot; `connect`/resume brings it back. Same as `lifecycle.onTimeout: "pause"`.
11
+ * - `delete`: destroy it. After a `pause` it deletes the paused sandbox (at most 27 days after the pause on Solari
12
+ * cloud); with no `pause` stage it is `lifecycle.onTimeout: "kill"`.
13
+ *
14
+ * Values are a duration string (`"30s"`, `"5m"`, `"1h30m"`, `"1d"`; units ms, s, m, h, d) or a number of milliseconds.
15
+ */
16
+ export interface IdlePolicyStages {
17
+ park?: string | number;
18
+ hibernate?: string | number;
19
+ pause?: string | number;
20
+ delete?: string | number;
21
+ }
22
+ /**
23
+ * Staged idle policy: what happens as a sandbox sits idle, in one setting. Either the string form
24
+ * (`"park@30s,hibernate@2m,pause@5m,delete@1d"`) or {@link IdlePolicyStages}. Do not combine it with
25
+ * `idleTimeoutMs`/`timeoutMs`/`ttlSeconds` or `lifecycle.onTimeout`/`parkAfterMs`/`parkMaxMs`/`hibernateAfterParkedMs`;
26
+ * the gateway answers 400 naming the clashing field. `lifecycle.autoResume` and `lifecycle.checkpointEveryMs` still apply.
27
+ *
28
+ * If the policy has a `park` (or `hibernate`) stage but no `pause` or `delete`, the gateway adds `pause` 24 hours after
29
+ * the last stage so a parked sandbox cannot hold its slot for the plan's whole maximum; the sandbox view's `idlePolicy`
30
+ * shows the policy as applied. What is billed: running time at the normal rate; parked time is free; hibernated and
31
+ * paused sandboxes are not billed for compute.
32
+ */
33
+ export type IdlePolicy = string | IdlePolicyStages;
4
34
  /** What the gateway does when a session's idle/ttl timeout fires. */
5
35
  export type OnTimeout = "pause" | "kill";
6
36
  /**
@@ -105,6 +135,8 @@ export interface CreateDesktopOptions {
105
135
  idleTimeoutMs?: number;
106
136
  /** Idle lifecycle policy (pause/kill on timeout; optional auto-resume). */
107
137
  lifecycle?: SandboxLifecycle;
138
+ /** Staged idle policy (park, hibernate, pause, delete as the sandbox sits idle); see {@link IdlePolicy}. Replaces `idleTimeoutMs` + most of `lifecycle`. */
139
+ idlePolicy?: IdlePolicy;
108
140
  /** Persistent volumes to mount before the session starts (Part V.4). */
109
141
  volumes?: VolumeAttachment[];
110
142
  }
@@ -120,6 +152,7 @@ export interface CreateDesktopRequest {
120
152
  record?: boolean;
121
153
  timeoutMs?: number;
122
154
  lifecycle?: SandboxLifecycle;
155
+ idlePolicy?: IdlePolicy;
123
156
  volumes?: VolumeAttachment[];
124
157
  }
125
158
  /** `201` response from `POST /desktops`. */
@@ -155,10 +188,25 @@ export interface GatewayErrorBody {
155
188
  /** Gateway hint that the failure is transient (e.g. 503 "No desktop host
156
189
  * available"). The HTTP transport retries idempotent requests when set. */
157
190
  retryable?: boolean;
191
+ /** The gateway's id for this request (also the `x-request-id` response header). Quote it in support requests. */
192
+ requestId?: string;
193
+ /** On every `429`: `"rate_limited"` (retryable, honour `Retry-After`) or `"quota_exceeded"` (a cap; retrying cannot help). */
194
+ errorType?: "rate_limited" | "quota_exceeded";
195
+ /** Seconds to wait before retrying, mirrored from the `Retry-After` header by the transport. */
196
+ retryAfterSeconds?: number;
158
197
  /** Current sandbox state, set on `409 NotStopped` / `409 StartInProgress`. */
159
198
  state?: string;
160
199
  /** Set on `409 HostCapabilityMissing`: capabilities the host pool lacks (e.g. `["start"]`). */
161
200
  missingCapabilities?: string[];
201
+ /** Set on `409 SnapshotNotRestorable`: why. `"golden_mismatch"` = the snapshot was taken on a different
202
+ * template build than the fleet runs now. Absent on the older (CPU-platform / pre-migration) refusals. */
203
+ reason?: string;
204
+ /** Set on `409 SnapshotNotRestorable`: the snapshot, where the request named one. */
205
+ snapshotId?: string;
206
+ /** Set with `reason: "golden_mismatch"`: the `golden.imageKey` the snapshot was taken on (when the host reports it). */
207
+ snapshotImageKey?: string;
208
+ /** Set with `reason: "golden_mismatch"`: the `golden.imageKey` of the host that refused. */
209
+ hostImageKey?: string;
162
210
  }
163
211
  /** `kind` of a session: headless `sandbox` or GUI `desktop`. */
164
212
  export type SandboxKind = "sandbox" | "desktop";
@@ -228,12 +276,22 @@ export interface CreateSandboxOptions {
228
276
  isolation?: SandboxIsolation;
229
277
  /** Idle lifecycle policy (pause/kill on timeout; optional auto-resume). */
230
278
  lifecycle?: SandboxLifecycle;
279
+ /** Staged idle policy (park, hibernate, pause, delete as the sandbox sits idle); see {@link IdlePolicy}. Replaces `idleTimeoutMs` + most of `lifecycle`. */
280
+ idlePolicy?: IdlePolicy;
231
281
  /**
232
282
  * Persistent volumes to mount into the session before it starts (Part V.4).
233
283
  * Each `volumeId` must belong to your org (create one via `client.volumes`).
234
284
  * The mount appears at the given absolute `path` inside the guest.
235
285
  */
236
286
  volumes?: VolumeAttachment[];
287
+ /**
288
+ * Run commands, code and file operations as this user (created at first boot with passwordless sudo if the image has
289
+ * no such user); a per-call `user` overrides it. Default: root. Not available on `isolation: "shared"`. The create
290
+ * response echoes `user` when the gateway honoured it; an older gateway ignores it or answers 400.
291
+ */
292
+ user?: string;
293
+ /** Default working directory (absolute). Default: the user's home. Same availability as {@link user}. */
294
+ cwd?: string;
237
295
  }
238
296
  /** One attach-at-create instruction: a persistent volume + its guest path. */
239
297
  export interface VolumeAttachment {
@@ -287,7 +345,26 @@ export interface CreateSandboxRequest {
287
345
  resolution?: string;
288
346
  record?: boolean;
289
347
  lifecycle?: SandboxLifecycle;
348
+ idlePolicy?: IdlePolicy;
290
349
  volumes?: VolumeAttachment[];
350
+ user?: string;
351
+ cwd?: string;
352
+ }
353
+ /**
354
+ * What a sandbox (or the sandbox a snapshot was taken from) actually ran on. Goldens are content-
355
+ * addressed: `imageKey` is `sha256(rootfsSha256 | kernelSha256 | chVersion)`, so equal `imageKey`s
356
+ * mean byte-identical rootfs, kernel and VMM. `template` is only the name; this is the bits behind it.
357
+ */
358
+ export interface GoldenInfo {
359
+ imageKey: string;
360
+ /** Template name the golden was built for. */
361
+ template?: string;
362
+ /** SHA-256 of the root filesystem image. */
363
+ rootfsSha256?: string;
364
+ /** SHA-256 of the guest kernel. */
365
+ kernelSha256?: string;
366
+ /** Cloud Hypervisor (VMM) version. */
367
+ chVersion?: string;
291
368
  }
292
369
  /** `201` response from `POST /sandboxes`. */
293
370
  export interface CreateSandboxResponse {
@@ -303,6 +380,13 @@ export interface CreateSandboxResponse {
303
380
  * `RecordingRequiresDesktop`) — it has no display to capture.
304
381
  */
305
382
  recordingUrl?: string;
383
+ /** The default user and working directory, echoed when the gateway honoured `user` / `cwd` on create. */
384
+ user?: string;
385
+ cwd?: string;
386
+ /** The template the sandbox was created from. */
387
+ template?: string;
388
+ /** The golden it runs on (rootfs / kernel / VMM digests); absent when the host did not report one. */
389
+ golden?: GoldenInfo;
306
390
  }
307
391
  /** `GET /sandboxes/:id` response. */
308
392
  export interface SandboxView {
@@ -328,6 +412,14 @@ export interface SandboxView {
328
412
  diskGb?: number;
329
413
  /** Presigned playback URL, present for a recorded `kind:"desktop"` session. */
330
414
  recordingUrl?: string;
415
+ /** The staged idle policy as the gateway applies it (including a `pause` stage it added), when one was set. */
416
+ idlePolicy?: string;
417
+ /** Set while the sandbox is paused under an `idlePolicy` with a `delete` stage: when it will be deleted (ISO 8601). */
418
+ deleteAt?: string;
419
+ /** The template this sandbox was created from. */
420
+ template?: string;
421
+ /** The golden it runs on (rootfs / kernel / VMM digests); absent when the host did not report one. */
422
+ golden?: GoldenInfo;
331
423
  }
332
424
  /** `GET /sandboxes` response (paginated). */
333
425
  export interface ListSandboxesResponse {
@@ -358,6 +450,8 @@ export interface SnapshotView {
358
450
  createdAt: string;
359
451
  kind: SandboxKind;
360
452
  template: string;
453
+ /** The golden the source sandbox ran on when the snapshot was taken; absent for older snapshots. */
454
+ golden?: GoldenInfo;
361
455
  }
362
456
  /** `POST /sandboxes/:id/snapshots` response. */
363
457
  export interface CreateSnapshotResponse {
@@ -374,6 +468,8 @@ export interface ListSnapshotsOptions {
374
468
  limit?: number;
375
469
  /** Take a previous page's `nextCursor` to get the page after it. */
376
470
  cursor?: string;
471
+ /** Only snapshots taken from this sandbox. */
472
+ sandboxId?: string;
377
473
  }
378
474
  /** `GET /snapshots` response: newest snapshots first, paged with an opaque cursor. */
379
475
  export interface ListSnapshotsResponse {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Shared runtime for the Solari SDK \u2014 transport, session handles, images, templates, and types used by @solarisdk/desktop and @solarisdk/sandbox.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",