@solarisdk/core 0.4.1 → 0.4.3
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 +1 -1
- package/dist/desktop.js +23 -4
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +21 -0
- package/dist/handle.d.ts +18 -3
- package/dist/handle.js +103 -43
- package/dist/http.d.ts +5 -0
- package/dist/http.js +26 -2
- package/dist/idle-policy.d.ts +7 -0
- package/dist/idle-policy.js +10 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/types.d.ts +85 -0
- package/package.json +1 -1
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`
|
|
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/desktop.js
CHANGED
|
@@ -46,11 +46,19 @@ export class Desktop extends SessionHandle {
|
|
|
46
46
|
stream: opts.stream,
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
execStream(cmd, onChunk, opts = {}) {
|
|
50
|
-
|
|
49
|
+
async execStream(cmd, onChunk, opts = {}) {
|
|
50
|
+
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
|
51
|
+
const result = await this.rpcStream("exec", { cmd, args: opts.args ?? [], cwd: opts.cwd, timeoutMs: opts.timeoutMs, stream: true }, (c) => {
|
|
51
52
|
const bytes = c.data ? base64ToBytes(c.data) : new Uint8Array();
|
|
52
|
-
|
|
53
|
+
const dec = decoders[c.stream] ?? decoders.stdout;
|
|
54
|
+
onChunk({ stream: c.stream, text: dec.decode(bytes, { stream: true }), bytes });
|
|
53
55
|
});
|
|
56
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
57
|
+
const rest = decoders[stream].decode();
|
|
58
|
+
if (rest)
|
|
59
|
+
onChunk({ stream, text: rest, bytes: new Uint8Array() });
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
54
62
|
}
|
|
55
63
|
fs = {
|
|
56
64
|
read: (path) => this.files.read(path),
|
|
@@ -126,7 +134,18 @@ export class Desktop extends SessionHandle {
|
|
|
126
134
|
process = {
|
|
127
135
|
list: async () => {
|
|
128
136
|
const r = await this.guiCall("process.list", {});
|
|
129
|
-
return r.processes
|
|
137
|
+
return (r.processes ?? []).map((p) => {
|
|
138
|
+
const info = {
|
|
139
|
+
pid: Number(p.pid),
|
|
140
|
+
name: String(p.name ?? p.comm ?? ""),
|
|
141
|
+
};
|
|
142
|
+
const cmd = p.cmd ?? p.cmdline;
|
|
143
|
+
if (typeof cmd === "string")
|
|
144
|
+
info.cmd = cmd;
|
|
145
|
+
if (typeof p.state === "string")
|
|
146
|
+
info.state = p.state;
|
|
147
|
+
return info;
|
|
148
|
+
});
|
|
130
149
|
},
|
|
131
150
|
kill: (pid) => this.guiCall("process.kill", { pid }).then(() => undefined),
|
|
132
151
|
start: async (cmd, opts = {}) => {
|
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/handle.d.ts
CHANGED
|
@@ -58,12 +58,18 @@ export interface SessionConfig extends ControlChannelConfig {
|
|
|
58
58
|
export declare const DEFAULT_COMMAND_TIMEOUT_MS = 600000;
|
|
59
59
|
/** Cap on PTY output held before the first onData() subscriber (bytes, total). */
|
|
60
60
|
export declare const PTY_EARLY_BUFFER_MAX: number;
|
|
61
|
+
export interface SessionHandle {
|
|
62
|
+
/** `await using sbx = await client.create(...)` releases the machine (`kill()`) when the scope ends. */
|
|
63
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
64
|
+
}
|
|
61
65
|
export declare class SessionHandle {
|
|
62
66
|
/** The session id (sandboxId / sessionId). */
|
|
63
67
|
readonly id: string;
|
|
64
68
|
readonly controlUrl: string;
|
|
65
69
|
readonly expiresAt: string;
|
|
66
70
|
protected readonly channel: ControlChannel;
|
|
71
|
+
/** Set once the gateway has refused a command stream because this sandbox is `shared`. */
|
|
72
|
+
private sharedContext;
|
|
67
73
|
protected readonly hooks: SessionHooks;
|
|
68
74
|
/** Set by {@link kill} so {@link close} doesn't warn about a machine we
|
|
69
75
|
* have just released. */
|
|
@@ -198,10 +204,19 @@ export declare class SessionHandle {
|
|
|
198
204
|
};
|
|
199
205
|
/** Run `git <args>` in the guest via the command RPC (no shell). */
|
|
200
206
|
private runGit;
|
|
201
|
-
/** Run `git <args>`; throw with the stderr on a non-zero exit. */
|
|
207
|
+
/** Run `git <args>`; throw with the stderr on a non-zero exit. A password passed in `secret` is masked in the message. */
|
|
202
208
|
private mustGit;
|
|
203
|
-
/**
|
|
204
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Credentials for ONE git command, for a private http(s) remote at `remoteUrl`. They go to the guest as environment
|
|
211
|
+
* variables of that command and git reads them back through an inline credential helper, so they are never in the
|
|
212
|
+
* clone URL (and so not in `.git/config`, a snapshot or a template), never in argv (`/proc/<pid>/cmdline`), and never
|
|
213
|
+
* in a config file: the `-c` options last for this command only and the helper script holds no secret.
|
|
214
|
+
*
|
|
215
|
+
* The helper is bound to the remote's origin (`credential.<scheme>://<host>.helper`), so git never hands the password
|
|
216
|
+
* to another host: a redirect to a different host, or a downgrade to http, gets nothing. Returns undefined when
|
|
217
|
+
* there are no credentials, or the remote is not http(s) (ssh remotes do not use them).
|
|
218
|
+
*/
|
|
219
|
+
private gitAuth;
|
|
205
220
|
private gitClone;
|
|
206
221
|
private gitStatus;
|
|
207
222
|
private gitAdd;
|
package/dist/handle.js
CHANGED
|
@@ -23,11 +23,23 @@ function trackUnreleased(token, id) {
|
|
|
23
23
|
}
|
|
24
24
|
const MIN_REST_TIMEOUT_MS = 1000;
|
|
25
25
|
export const PTY_EARLY_BUFFER_MAX = 1024 * 1024;
|
|
26
|
+
const GIT_CREDENTIAL_HELPER = '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$SOLARI_GIT_USERNAME" "$SOLARI_GIT_PASSWORD"; }; f';
|
|
27
|
+
function gitSubcommand(args) {
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (args[i] === "-c")
|
|
30
|
+
i += 2;
|
|
31
|
+
return args[i] ?? "";
|
|
32
|
+
}
|
|
33
|
+
function rejectOptionLike(what, v) {
|
|
34
|
+
if (v.startsWith("-"))
|
|
35
|
+
throw new Error(`git ${what} must not start with "-": ${JSON.stringify(v)}`);
|
|
36
|
+
}
|
|
26
37
|
export class SessionHandle {
|
|
27
38
|
id;
|
|
28
39
|
controlUrl;
|
|
29
40
|
expiresAt;
|
|
30
41
|
channel;
|
|
42
|
+
sharedContext = false;
|
|
31
43
|
hooks;
|
|
32
44
|
killed = false;
|
|
33
45
|
releaseToken = {};
|
|
@@ -120,17 +132,26 @@ export class SessionHandle {
|
|
|
120
132
|
opts.onStderr?.(c.data);
|
|
121
133
|
});
|
|
122
134
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const data = f.base64 ? new TextDecoder().decode(base64ToBytes(f.base64)) : "";
|
|
126
|
-
const chunk = { stream, data };
|
|
135
|
+
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
|
|
136
|
+
const emit = (chunk) => {
|
|
127
137
|
if (dataCbs.length === 0)
|
|
128
138
|
buffered.push(chunk);
|
|
129
139
|
else
|
|
130
140
|
for (const cb of dataCbs)
|
|
131
141
|
cb(chunk);
|
|
142
|
+
};
|
|
143
|
+
this.channel.onFrame("cmd.data", cmdId, (f) => {
|
|
144
|
+
const stream = f.stream ?? "stdout";
|
|
145
|
+
const dec = decoders[stream] ?? decoders.stdout;
|
|
146
|
+
const data = f.base64 ? dec.decode(base64ToBytes(f.base64), { stream: true }) : "";
|
|
147
|
+
emit({ stream, data });
|
|
132
148
|
});
|
|
133
149
|
this.channel.onFrame("cmd.exit", cmdId, (f) => {
|
|
150
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
151
|
+
const rest = decoders[stream].decode();
|
|
152
|
+
if (rest)
|
|
153
|
+
emit({ stream, data: rest });
|
|
154
|
+
}
|
|
134
155
|
this.channel.offFrame("cmd.data", cmdId);
|
|
135
156
|
this.channel.offFrame("cmd.exit", cmdId);
|
|
136
157
|
this.channel.offStreamClose(cmdId);
|
|
@@ -172,23 +193,21 @@ export class SessionHandle {
|
|
|
172
193
|
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
173
194
|
throw new SolariError(`Solari: timeoutMs must be a non-negative number of milliseconds, got ${String(timeoutMs)}`);
|
|
174
195
|
}
|
|
175
|
-
|
|
196
|
+
const restEligible = !!this.hooks.exec &&
|
|
176
197
|
!opts.background &&
|
|
177
198
|
!opts.onStdout &&
|
|
178
199
|
!opts.onStderr &&
|
|
179
200
|
!opts.env &&
|
|
180
201
|
!opts.user &&
|
|
181
202
|
timeoutMs >= MIN_REST_TIMEOUT_MS &&
|
|
182
|
-
timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS
|
|
183
|
-
|
|
203
|
+
timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS;
|
|
204
|
+
const viaRest = async () => {
|
|
205
|
+
const r = await this.hooks.exec(this.id, { cmd, args: opts.args, cwd: opts.cwd, timeoutMs });
|
|
206
|
+
return { ...r, timedOut: r.timedOut === true };
|
|
207
|
+
};
|
|
208
|
+
if (restEligible && (!this.channel.connected || this.sharedContext)) {
|
|
184
209
|
try {
|
|
185
|
-
|
|
186
|
-
cmd,
|
|
187
|
-
args: opts.args,
|
|
188
|
-
cwd: opts.cwd,
|
|
189
|
-
timeoutMs,
|
|
190
|
-
});
|
|
191
|
-
return { ...r, timedOut: r.timedOut === true };
|
|
210
|
+
return await viaRest();
|
|
192
211
|
}
|
|
193
212
|
catch (err) {
|
|
194
213
|
if (!isRouteUnavailable(err))
|
|
@@ -196,7 +215,24 @@ export class SessionHandle {
|
|
|
196
215
|
}
|
|
197
216
|
}
|
|
198
217
|
const acc = { stdout: "", stderr: "" };
|
|
199
|
-
|
|
218
|
+
let handle;
|
|
219
|
+
try {
|
|
220
|
+
handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (!isSharedRefusal(err))
|
|
224
|
+
throw err;
|
|
225
|
+
this.sharedContext = true;
|
|
226
|
+
if (restEligible) {
|
|
227
|
+
try {
|
|
228
|
+
return await viaRest();
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
throw normalizeExecError(e, timeoutMs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
200
236
|
if (opts.background) {
|
|
201
237
|
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
|
202
238
|
}
|
|
@@ -378,42 +414,61 @@ export class SessionHandle {
|
|
|
378
414
|
branches: (cwd) => this.gitBranches(cwd),
|
|
379
415
|
log: (opts = {}) => this.gitLog(opts),
|
|
380
416
|
};
|
|
381
|
-
async runGit(args, cwd) {
|
|
382
|
-
return this.runCommand("git", { args, ...(cwd ? { cwd } : {}) });
|
|
417
|
+
async runGit(args, cwd, env) {
|
|
418
|
+
return this.runCommand("git", { args, ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) });
|
|
383
419
|
}
|
|
384
|
-
async mustGit(args, cwd) {
|
|
385
|
-
const r = await this.runGit(args, cwd);
|
|
420
|
+
async mustGit(args, cwd, env, secret) {
|
|
421
|
+
const r = await this.runGit(args, cwd, env);
|
|
386
422
|
if (r.exitCode !== 0) {
|
|
387
|
-
|
|
388
|
-
|
|
423
|
+
let detail = (r.stderr || r.stdout || "").trim();
|
|
424
|
+
if (secret)
|
|
425
|
+
detail = detail.split(secret).join("***");
|
|
426
|
+
throw new Error(`git ${gitSubcommand(args)} failed (exit ${r.exitCode}): ${detail}`);
|
|
389
427
|
}
|
|
390
428
|
return r.stdout;
|
|
391
429
|
}
|
|
392
|
-
|
|
430
|
+
gitAuth(remoteUrl, username, password) {
|
|
393
431
|
if (!username && !password)
|
|
394
|
-
return
|
|
432
|
+
return undefined;
|
|
433
|
+
for (const v of [username, password]) {
|
|
434
|
+
if (v !== undefined && /[\r\n\0]/.test(v))
|
|
435
|
+
throw new Error("git credentials must not contain line breaks or NUL");
|
|
436
|
+
}
|
|
437
|
+
let origin;
|
|
395
438
|
try {
|
|
396
|
-
const u = new URL(
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
u.password = encodeURIComponent(password);
|
|
401
|
-
return u.toString();
|
|
439
|
+
const u = new URL(remoteUrl);
|
|
440
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
441
|
+
return undefined;
|
|
442
|
+
origin = `${u.protocol}//${u.host}`;
|
|
402
443
|
}
|
|
403
444
|
catch {
|
|
404
|
-
return
|
|
445
|
+
return undefined;
|
|
405
446
|
}
|
|
447
|
+
if (/[=\s]/.test(origin))
|
|
448
|
+
return undefined;
|
|
449
|
+
return {
|
|
450
|
+
cfg: [
|
|
451
|
+
"-c", "credential.helper=",
|
|
452
|
+
"-c", `credential.${origin}.helper=${GIT_CREDENTIAL_HELPER}`,
|
|
453
|
+
],
|
|
454
|
+
env: {
|
|
455
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
456
|
+
SOLARI_GIT_USERNAME: username ?? "",
|
|
457
|
+
SOLARI_GIT_PASSWORD: password ?? "",
|
|
458
|
+
},
|
|
459
|
+
};
|
|
406
460
|
}
|
|
407
461
|
async gitClone(url, opts) {
|
|
408
|
-
const
|
|
462
|
+
const auth = this.gitAuth(url, opts.username, opts.password);
|
|
463
|
+
const args = [...(auth?.cfg ?? []), "clone"];
|
|
409
464
|
if (opts.depth && opts.depth > 0)
|
|
410
465
|
args.push("--depth", String(opts.depth));
|
|
411
466
|
if (opts.branch)
|
|
412
467
|
args.push("--branch", opts.branch);
|
|
413
|
-
args.push(
|
|
468
|
+
args.push("--", url);
|
|
414
469
|
if (opts.path)
|
|
415
470
|
args.push(opts.path);
|
|
416
|
-
await this.mustGit(args, opts.cwd);
|
|
471
|
+
await this.mustGit(args, opts.cwd, auth?.env, opts.password);
|
|
417
472
|
}
|
|
418
473
|
async gitStatus(cwd) {
|
|
419
474
|
const out = await this.mustGit(["status", "--porcelain=v1", "--branch"], cwd);
|
|
@@ -488,25 +543,25 @@ export class SessionHandle {
|
|
|
488
543
|
}
|
|
489
544
|
async gitPushPull(op, opts) {
|
|
490
545
|
const remote = opts.remote || "origin";
|
|
491
|
-
|
|
546
|
+
rejectOptionLike("remote", remote);
|
|
547
|
+
if (opts.branch)
|
|
548
|
+
rejectOptionLike("branch", opts.branch);
|
|
549
|
+
let auth;
|
|
492
550
|
if (opts.username || opts.password) {
|
|
493
|
-
const plain = (await this.
|
|
494
|
-
|
|
495
|
-
const authed = this.authUrl(plain, opts.username, opts.password);
|
|
496
|
-
if (authed !== plain)
|
|
497
|
-
cfg.push("-c", `url.${authed}.insteadOf=${plain}`);
|
|
498
|
-
}
|
|
551
|
+
const plain = (await this.mustGit(["remote", "get-url", "--", remote], opts.cwd)).trim();
|
|
552
|
+
auth = this.gitAuth(plain, opts.username, opts.password);
|
|
499
553
|
}
|
|
500
|
-
const args = [...cfg, op, remote];
|
|
554
|
+
const args = [...(auth?.cfg ?? []), op, "--", remote];
|
|
501
555
|
if (opts.branch)
|
|
502
556
|
args.push(opts.branch);
|
|
503
|
-
await this.mustGit(args, opts.cwd);
|
|
557
|
+
await this.mustGit(args, opts.cwd, auth?.env, opts.password);
|
|
504
558
|
}
|
|
505
559
|
async gitCheckout(ref, opts) {
|
|
560
|
+
rejectOptionLike("ref", ref);
|
|
506
561
|
const args = ["checkout"];
|
|
507
562
|
if (opts.create)
|
|
508
563
|
args.push("-b");
|
|
509
|
-
args.push(ref);
|
|
564
|
+
args.push(ref, "--");
|
|
510
565
|
await this.mustGit(args, opts.cwd);
|
|
511
566
|
}
|
|
512
567
|
async gitBranches(cwd) {
|
|
@@ -652,6 +707,11 @@ function explainShellCommandFailure(cmd, err) {
|
|
|
652
707
|
}
|
|
653
708
|
return new Error(msg + hint);
|
|
654
709
|
}
|
|
710
|
+
function isSharedRefusal(err) {
|
|
711
|
+
if (err instanceof ActionError && err.code === "NotAvailableOnSharedIsolation")
|
|
712
|
+
return true;
|
|
713
|
+
return err instanceof Error && /NotAvailableOnSharedIsolation|not available on a shared/.test(err.message);
|
|
714
|
+
}
|
|
655
715
|
function isRouteUnavailable(err) {
|
|
656
716
|
return (err instanceof GatewayError &&
|
|
657
717
|
(err.status === 404 || err.status === 405 || err.status === 501));
|
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
|
-
|
|
76
|
-
|
|
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,6 +276,8 @@ 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`).
|
|
@@ -295,10 +345,27 @@ export interface CreateSandboxRequest {
|
|
|
295
345
|
resolution?: string;
|
|
296
346
|
record?: boolean;
|
|
297
347
|
lifecycle?: SandboxLifecycle;
|
|
348
|
+
idlePolicy?: IdlePolicy;
|
|
298
349
|
volumes?: VolumeAttachment[];
|
|
299
350
|
user?: string;
|
|
300
351
|
cwd?: string;
|
|
301
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;
|
|
368
|
+
}
|
|
302
369
|
/** `201` response from `POST /sandboxes`. */
|
|
303
370
|
export interface CreateSandboxResponse {
|
|
304
371
|
sandboxId: string;
|
|
@@ -316,6 +383,10 @@ export interface CreateSandboxResponse {
|
|
|
316
383
|
/** The default user and working directory, echoed when the gateway honoured `user` / `cwd` on create. */
|
|
317
384
|
user?: string;
|
|
318
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;
|
|
319
390
|
}
|
|
320
391
|
/** `GET /sandboxes/:id` response. */
|
|
321
392
|
export interface SandboxView {
|
|
@@ -341,6 +412,14 @@ export interface SandboxView {
|
|
|
341
412
|
diskGb?: number;
|
|
342
413
|
/** Presigned playback URL, present for a recorded `kind:"desktop"` session. */
|
|
343
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;
|
|
344
423
|
}
|
|
345
424
|
/** `GET /sandboxes` response (paginated). */
|
|
346
425
|
export interface ListSandboxesResponse {
|
|
@@ -371,6 +450,8 @@ export interface SnapshotView {
|
|
|
371
450
|
createdAt: string;
|
|
372
451
|
kind: SandboxKind;
|
|
373
452
|
template: string;
|
|
453
|
+
/** The golden the source sandbox ran on when the snapshot was taken; absent for older snapshots. */
|
|
454
|
+
golden?: GoldenInfo;
|
|
374
455
|
}
|
|
375
456
|
/** `POST /sandboxes/:id/snapshots` response. */
|
|
376
457
|
export interface CreateSnapshotResponse {
|
|
@@ -605,8 +686,12 @@ export interface ProcessListParams {
|
|
|
605
686
|
}
|
|
606
687
|
export interface ProcessInfo {
|
|
607
688
|
pid: number;
|
|
689
|
+
/** The process name (the guest agent's `comm`). */
|
|
608
690
|
name: string;
|
|
691
|
+
/** The full command line (the guest agent's `cmdline`). */
|
|
609
692
|
cmd?: string;
|
|
693
|
+
/** Process state letter as the kernel reports it (`R`, `S`, `Z`, ...). */
|
|
694
|
+
state?: string;
|
|
610
695
|
}
|
|
611
696
|
export interface ProcessListResult {
|
|
612
697
|
processes: ProcessInfo[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solarisdk/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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",
|