@solarisdk/core 0.4.3 → 0.4.5

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/desktop.d.ts CHANGED
@@ -41,6 +41,11 @@ export declare class Desktop extends SessionHandle {
41
41
  doubleClick: (x: number, y: number, opts?: ClickOptions) => Promise<void>;
42
42
  down: (x: number, y: number, button?: MouseButton) => Promise<void>;
43
43
  up: (x: number, y: number, button?: MouseButton) => Promise<void>;
44
+ /**
45
+ * Scroll the wheel with the pointer at (x, y). `direction` defaults to "down", `amount` (wheel clicks, 1-50)
46
+ * to 3. The guest takes the wheel delta in the `y` field and does not move the pointer, so the pointer is moved
47
+ * first and (x, y) is never passed as the delta (it used to be: scroll(x, 500) fired 500 clicks, 20-30 s).
48
+ */
44
49
  scroll: (x: number, y: number, opts?: ScrollOptions) => Promise<void>;
45
50
  drag: (from: {
46
51
  x: number;
package/dist/desktop.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SessionHandle } from "./handle.js";
2
+ import { SolariError } from "./errors.js";
2
3
  import { base64ToBytes } from "./transport.js";
3
4
  function buttonToCode(button) {
4
5
  if (button === undefined)
@@ -79,7 +80,23 @@ export class Desktop extends SessionHandle {
79
80
  }).then(() => undefined),
80
81
  down: (x, y, button = "left") => this.mouseCall(x, y, "down", button),
81
82
  up: (x, y, button = "left") => this.mouseCall(x, y, "up", button),
82
- scroll: (x, y, opts = {}) => this.mouseCall(x, y, "scroll", opts.button, opts.humanize),
83
+ scroll: async (x, y, opts = {}) => {
84
+ const amount = opts.amount ?? 3;
85
+ if (!Number.isInteger(amount) || amount < 1 || amount > 50) {
86
+ throw new SolariError("scroll amount must be a whole number of wheel clicks from 1 to 50");
87
+ }
88
+ const direction = opts.direction ?? "down";
89
+ if (direction !== "up" && direction !== "down") {
90
+ throw new SolariError('scroll direction must be "up" or "down"');
91
+ }
92
+ await this.mouseCall(x, y, "move", undefined, opts.humanize);
93
+ await this.guiCall("input.mouse", {
94
+ x,
95
+ y: direction === "up" ? amount : -amount,
96
+ action: "scroll",
97
+ button: buttonToCode(opts.button),
98
+ });
99
+ },
83
100
  drag: (from, to, button = "left") => this.guiCall("input.drag", { from, to, button: buttonToCode(button) }).then(() => undefined),
84
101
  };
85
102
  mouseCall(x, y, action, button, humanize) {
package/dist/handle.d.ts CHANGED
@@ -56,6 +56,16 @@ export interface SessionConfig extends ControlChannelConfig {
56
56
  }
57
57
  /** The longest `timeoutMs` the gateway accepts for a command (600 000 ms), and the default for `commands.run`. */
58
58
  export declare const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
59
+ /**
60
+ * The session `id` is no longer running (killed or paused, through any handle or by id), so the exit reminder must not
61
+ * name it. Tracking is by id, not by handle: two handles to one session, or a `client.kill(id)` with no handle at all,
62
+ * all clear the same entry.
63
+ */
64
+ export declare function markSessionReleased(id: string): void;
65
+ /** The lines the exit reminder prints: one per distinct session id, however many handles track it. */
66
+ export declare function exitReminderMessages(): string[];
67
+ /** Ids the exit reminder would name right now (for tests). */
68
+ export declare function unreleasedSessionIds(): string[];
59
69
  /** Cap on PTY output held before the first onData() subscriber (bytes, total). */
60
70
  export declare const PTY_EARLY_BUFFER_MAX: number;
61
71
  export interface SessionHandle {
@@ -121,6 +131,8 @@ export declare class SessionHandle {
121
131
  connect: () => Promise<void>;
122
132
  };
123
133
  private startCommand;
134
+ /** SIGTERM + close stdin now, SIGKILL after CANCEL_KILL_GRACE_MS. Best effort: a dead channel or a finished command is fine. */
135
+ private cancelCommand;
124
136
  private runCommand;
125
137
  private runCommandInner;
126
138
  readonly pty: {
@@ -136,6 +148,7 @@ export declare class SessionHandle {
136
148
  /** Create a fresh stateful kernel context (`code.context.create`). */
137
149
  createCodeContext(language?: CodeLanguageArg): Promise<string>;
138
150
  readonly files: {
151
+ /** Read a whole file. Chunked under the hood (4 MiB per frame), so a big file never travels as one frame. */
139
152
  read: (path: string) => Promise<Uint8Array>;
140
153
  readText: (path: string) => Promise<string>;
141
154
  write: (path: string, data: Uint8Array | string, mode?: number) => Promise<void>;
@@ -151,7 +164,7 @@ export declare class SessionHandle {
151
164
  * Returns an unwatch function.
152
165
  */
153
166
  watch: (path: string, cb: (ev: FsWatchEvent) => void, recursive?: boolean) => Promise<() => Promise<void>>;
154
- /** Upload local bytes to an in-guest path (chunked base64 under the hood). */
167
+ /** Upload local bytes to an in-guest path: sent in 4 MiB chunks (offset/truncate), so a big file never travels as one frame. */
155
168
  upload: (path: string, data: Uint8Array | string) => Promise<void>;
156
169
  /** Download an in-guest file's bytes. */
157
170
  download: (path: string) => Promise<Uint8Array>;
@@ -172,10 +185,11 @@ export declare class SessionHandle {
172
185
  }>;
173
186
  readonly volumes: {
174
187
  /**
175
- * Mount a volume into the guest at `path`. Thin pass-through to the guest
176
- * `volume.mount` RPC; the gateway volume routes may be a 501 stub today.
188
+ * Not available at runtime. A volume is mounted when the sandbox is created (or resumed): the guest cannot fetch the
189
+ * storage credentials itself, and the gateway has no runtime-mount route, so the old pass-through to the guest
190
+ * `volume.mount` RPC always failed with "bucket is required". Pass `volumes: [{ volumeId, path }]` at create.
177
191
  */
178
- mount: (volId: string, path: string) => Promise<void>;
192
+ mount: (_volId: string, _path: string) => Promise<void>;
179
193
  };
180
194
  readonly git: {
181
195
  /** Clone `url` into `opts.path` (or git's default dir) under `opts.cwd`. */
@@ -289,4 +303,10 @@ export declare class SessionHandle {
289
303
  }
290
304
  /** Local helper type for `createCodeContext` to avoid a hard import cycle. */
291
305
  type CodeLanguageArg = "python" | "javascript" | "typescript" | "bash" | "r";
306
+ /** How long a cancelled command gets between SIGTERM and SIGKILL. */
307
+ export declare const CANCEL_KILL_GRACE_MS = 2000;
308
+ /** Bytes per fs.upload / fs.download frame. */
309
+ export declare const FILE_CHUNK_BYTES: number;
310
+ /** Largest whole-file payload one single-frame call (files.write) accepts; above it use files.upload / files.download / a signed URL. */
311
+ export declare const MAX_SINGLE_CALL_BYTES: number;
292
312
  export {};
package/dist/handle.js CHANGED
@@ -6,6 +6,18 @@ export const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
6
6
  const unreleased = new Map();
7
7
  const MAX_TRACKED_UNRELEASED = 1000;
8
8
  let exitHookInstalled = false;
9
+ export function markSessionReleased(id) {
10
+ for (const [token, tracked] of unreleased)
11
+ if (tracked === id)
12
+ unreleased.delete(token);
13
+ }
14
+ export function exitReminderMessages() {
15
+ return [...new Set(unreleased.values())].map((id) => `[solari] this process is exiting but session ${shortId(id)} is still running and will bill until its ` +
16
+ `idle timeout. Call kill() to release it (or declare it with \`await using\`).`);
17
+ }
18
+ export function unreleasedSessionIds() {
19
+ return [...unreleased.values()];
20
+ }
9
21
  function trackUnreleased(token, id) {
10
22
  unreleased.set(token, id);
11
23
  if (unreleased.size > MAX_TRACKED_UNRELEASED)
@@ -14,10 +26,8 @@ function trackUnreleased(token, id) {
14
26
  return;
15
27
  exitHookInstalled = true;
16
28
  process.on("exit", () => {
17
- for (const id of unreleased.values()) {
18
- console.warn(`[solari] this process is exiting but session ${shortId(id)} is still running and will bill until its ` +
19
- `idle timeout. Call kill() to release it (or declare it with \`await using\`).`);
20
- }
29
+ for (const line of exitReminderMessages())
30
+ console.warn(line);
21
31
  unreleased.clear();
22
32
  });
23
33
  }
@@ -70,7 +80,8 @@ export class SessionHandle {
70
80
  return this.channel.reconnect();
71
81
  }
72
82
  close(opts = {}) {
73
- unreleased.delete(this.releaseToken);
83
+ if (!opts.quiet)
84
+ unreleased.delete(this.releaseToken);
74
85
  if (!this.killed && !opts.quiet && typeof console !== "undefined") {
75
86
  console.warn(`[solari] close() on session ${shortId(this.id)} closed the local channel only — ` +
76
87
  `the remote machine is still running and will bill until its idle ` +
@@ -116,6 +127,22 @@ export class SessionHandle {
116
127
  exitResolve?.(-1);
117
128
  }, opts.timeoutMs);
118
129
  }
130
+ if (opts.signal) {
131
+ const signal = opts.signal;
132
+ const onAbort = () => {
133
+ clearTimeout(deadline);
134
+ void this.cancelCommand(cmdId);
135
+ this.channel.offFrame("cmd.data", cmdId);
136
+ this.channel.offFrame("cmd.exit", cmdId);
137
+ this.channel.offStreamClose(cmdId);
138
+ exitReject?.(abortReason(signal));
139
+ };
140
+ if (signal.aborted)
141
+ onAbort();
142
+ else
143
+ signal.addEventListener("abort", onAbort, { once: true });
144
+ void exitPromise.then(() => signal.removeEventListener("abort", onAbort), () => signal.removeEventListener("abort", onAbort));
145
+ }
119
146
  if (acc) {
120
147
  dataCbs.push((c) => {
121
148
  if (c.stream === "stdout")
@@ -180,6 +207,13 @@ export class SessionHandle {
180
207
  .then(() => undefined),
181
208
  };
182
209
  }
210
+ async cancelCommand(cmdId) {
211
+ await this.channel.call("cmd.kill", { cmdId, signal: 15 }).catch(() => undefined);
212
+ await this.channel.call("cmd.stdin", { cmdId, base64: "", eof: true }).catch(() => undefined);
213
+ setTimeout(() => {
214
+ this.channel.call("cmd.kill", { cmdId, signal: 9 }).catch(() => undefined);
215
+ }, CANCEL_KILL_GRACE_MS).unref?.();
216
+ }
183
217
  async runCommand(cmd, opts) {
184
218
  try {
185
219
  return await this.runCommandInner(cmd, opts);
@@ -199,6 +233,7 @@ export class SessionHandle {
199
233
  !opts.onStderr &&
200
234
  !opts.env &&
201
235
  !opts.user &&
236
+ !opts.signal &&
202
237
  timeoutMs >= MIN_REST_TIMEOUT_MS &&
203
238
  timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS;
204
239
  const viaRest = async () => {
@@ -309,11 +344,34 @@ export class SessionHandle {
309
344
  };
310
345
  }
311
346
  async runCode(code, opts = {}) {
312
- const r = await this.channel.call("code.run", {
313
- code,
314
- language: opts.language,
315
- contextId: opts.contextId,
316
- });
347
+ const params = { code, language: opts.language, contextId: opts.contextId };
348
+ const running = this.channel.call("code.run", params);
349
+ let r;
350
+ const signal = opts.signal;
351
+ if (signal) {
352
+ let onAbort;
353
+ const aborted = new Promise((_, reject) => {
354
+ onAbort = () => {
355
+ this.channel.call("code.interrupt", { language: opts.language, contextId: opts.contextId }).catch(() => undefined);
356
+ reject(abortReason(signal));
357
+ };
358
+ if (signal.aborted)
359
+ onAbort();
360
+ else
361
+ signal.addEventListener("abort", onAbort, { once: true });
362
+ });
363
+ running.catch(() => undefined);
364
+ try {
365
+ r = await Promise.race([running, aborted]);
366
+ }
367
+ finally {
368
+ if (onAbort)
369
+ signal.removeEventListener("abort", onAbort);
370
+ }
371
+ }
372
+ else {
373
+ r = await running;
374
+ }
317
375
  const results = Array.isArray(r.results) ? r.results : [];
318
376
  for (const item of results) {
319
377
  if (item.type === "stdout" && item.text)
@@ -332,16 +390,29 @@ export class SessionHandle {
332
390
  }
333
391
  files = {
334
392
  read: async (path) => {
335
- const r = await this.channel.call("fs.read", { path });
336
- return base64ToBytes(r.base64);
393
+ try {
394
+ return await downloadAll((method, params) => this.channel.call(method, { ...params, length: FILE_CHUNK_BYTES }), path);
395
+ }
396
+ catch (err) {
397
+ if (!isUnknownMethod(err))
398
+ throw err;
399
+ const r = await this.channel.call("fs.read", { path });
400
+ return base64ToBytes(r.base64);
401
+ }
337
402
  },
338
403
  readText: async (path) => {
339
404
  const bytes = await this.files.read(path);
340
405
  return new TextDecoder().decode(bytes);
341
406
  },
342
- write: (path, data, mode) => this.channel
343
- .call("fs.write", { path, base64: toB64(data), mode })
344
- .then(() => undefined),
407
+ write: (path, data, mode) => {
408
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
409
+ if (bytes.length > MAX_SINGLE_CALL_BYTES) {
410
+ return Promise.reject(singleCallTooLarge(bytes.length));
411
+ }
412
+ return this.channel
413
+ .call("fs.write", { path, base64: toB64(bytes), mode })
414
+ .then(() => undefined);
415
+ },
345
416
  list: async (path) => {
346
417
  const r = await this.channel.call("fs.list", {
347
418
  path,
@@ -376,10 +447,16 @@ export class SessionHandle {
376
447
  await this.channel.call("fs.unwatch", { watchId });
377
448
  };
378
449
  },
379
- upload: (path, data) => this.channel
380
- .call("fs.upload", { path, base64: toB64(data) })
381
- .then(() => undefined),
382
- download: (path) => downloadAll((method, params) => this.channel.call(method, params), path),
450
+ upload: async (path, data) => {
451
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
452
+ let offset = 0;
453
+ do {
454
+ const slice = bytes.subarray(offset, offset + FILE_CHUNK_BYTES);
455
+ await this.channel.call("fs.upload", { path, base64: toB64(slice), offset, truncate: offset === 0 });
456
+ offset += slice.length;
457
+ } while (offset < bytes.length);
458
+ },
459
+ download: (path) => downloadAll((method, params) => this.channel.call(method, { ...params, length: FILE_CHUNK_BYTES }), path),
383
460
  };
384
461
  env(vars) {
385
462
  return this.channel
@@ -399,9 +476,7 @@ export class SessionHandle {
399
476
  return this.hooks.uploadUrl(this.id, path);
400
477
  }
401
478
  volumes = {
402
- mount: (volId, path) => this.channel
403
- .call("volume.mount", { volumeId: volId, path })
404
- .then(() => undefined),
479
+ mount: (_volId, _path) => Promise.reject(new SolariError("volumes.mount() is not supported on a running sandbox: pass volumes: [{ volumeId, path }] when you create it")),
405
480
  };
406
481
  git = {
407
482
  clone: (url, opts = {}) => this.gitClone(url, opts),
@@ -624,6 +699,7 @@ export class SessionHandle {
624
699
  throw new Error("pause requires a client-created handle");
625
700
  }
626
701
  await this.hooks.pause(this.id);
702
+ markSessionReleased(this.id);
627
703
  this.close({ quiet: true });
628
704
  }
629
705
  async resume() {
@@ -631,6 +707,7 @@ export class SessionHandle {
631
707
  throw new Error("resume requires a client-created handle");
632
708
  }
633
709
  const r = await this.hooks.resume(this.id);
710
+ trackUnreleased(this.releaseToken, this.id);
634
711
  this.channel.setControlUrl(r.controlUrl);
635
712
  await this.channel.reconnect();
636
713
  }
@@ -673,6 +750,7 @@ export class SessionHandle {
673
750
  if (this.hooks.kill)
674
751
  await this.hooks.kill(this.id);
675
752
  this.killed = true;
753
+ markSessionReleased(this.id);
676
754
  this.close();
677
755
  }
678
756
  }
@@ -716,6 +794,23 @@ function isRouteUnavailable(err) {
716
794
  return (err instanceof GatewayError &&
717
795
  (err.status === 404 || err.status === 405 || err.status === 501));
718
796
  }
797
+ function isUnknownMethod(err) {
798
+ return /unknown method|method not found|not implemented/i.test(err instanceof Error ? err.message : String(err));
799
+ }
800
+ export const CANCEL_KILL_GRACE_MS = 2000;
801
+ function abortReason(signal) {
802
+ const r = signal.reason;
803
+ if (r instanceof Error)
804
+ return r;
805
+ const e = new Error("The command was cancelled");
806
+ e.name = "AbortError";
807
+ return e;
808
+ }
809
+ export const FILE_CHUNK_BYTES = 4 * 1024 * 1024;
810
+ export const MAX_SINGLE_CALL_BYTES = 32 * 1024 * 1024;
811
+ function singleCallTooLarge(n) {
812
+ return new SolariError(`file too large for a single call (${n} bytes, max ${MAX_SINGLE_CALL_BYTES}): use files.upload()/files.download() or uploadUrl()`);
813
+ }
719
814
  function toB64(data) {
720
815
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
721
816
  return bytesToBase64(bytes);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
1
+ export { CANCEL_KILL_GRACE_MS, DEFAULT_COMMAND_TIMEOUT_MS, FILE_CHUNK_BYTES, MAX_SINGLE_CALL_BYTES, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
3
  export { formatIdlePolicy } from "./idle-policy.js";
4
4
  export type { SessionConfig, SessionHooks } from "./handle.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { DEFAULT_COMMAND_TIMEOUT_MS, SessionHandle } from "./handle.js";
1
+ export { CANCEL_KILL_GRACE_MS, DEFAULT_COMMAND_TIMEOUT_MS, FILE_CHUNK_BYTES, MAX_SINGLE_CALL_BYTES, SessionHandle, markSessionReleased, unreleasedSessionIds, exitReminderMessages } from "./handle.js";
2
2
  export { shortId } from "./ids.js";
3
3
  export { formatIdlePolicy } from "./idle-policy.js";
4
4
  export { Desktop } from "./desktop.js";
package/dist/types.d.ts CHANGED
@@ -632,6 +632,10 @@ export interface MouseResult {
632
632
  export interface ScrollOptions {
633
633
  button?: MouseButton;
634
634
  humanize?: boolean;
635
+ /** Which way the wheel turns. Default "down". */
636
+ direction?: "up" | "down";
637
+ /** Wheel clicks, a whole number from 1 to 50. Default 3. */
638
+ amount?: number;
635
639
  }
636
640
  export interface ClickOptions {
637
641
  button?: MouseButton;
@@ -763,6 +767,12 @@ export interface CommandOptions {
763
767
  background?: boolean;
764
768
  onStdout?: (data: string) => void;
765
769
  onStderr?: (data: string) => void;
770
+ /**
771
+ * Cancel the command. On abort the process in the sandbox gets SIGTERM and its stdin is closed, then SIGKILL after
772
+ * 2 s if it is still running; the call rejects with the signal's reason. Giving a signal runs the command over the live
773
+ * connection (the one-shot REST route has nothing to kill).
774
+ */
775
+ signal?: AbortSignal;
766
776
  }
767
777
  /** Terminal result of `commands.run`. */
768
778
  export interface CommandResult {
@@ -831,6 +841,8 @@ export interface RunCodeOptions {
831
841
  contextId?: string;
832
842
  onStdout?: (data: string) => void;
833
843
  onStderr?: (data: string) => void;
844
+ /** Cancel the run: the cell is interrupted in the kernel (KeyboardInterrupt) and the call rejects with the signal's reason. */
845
+ signal?: AbortSignal;
834
846
  }
835
847
  /** Kind of a structured chart extracted from a matplotlib figure. */
836
848
  export type ChartType = "line" | "scatter" | "bar" | "pie" | "box_and_whisker" | "composite" | "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",