@solarisdk/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -13
- package/README.md +5 -1
- package/dist/chunked-download.d.ts +0 -1
- package/dist/chunked-download.js +0 -11
- package/dist/desktop.d.ts +0 -1
- package/dist/desktop.js +0 -52
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +16 -39
- package/dist/handle.d.ts +14 -2
- package/dist/handle.js +91 -234
- package/dist/http.d.ts +2 -1
- package/dist/http.js +1 -49
- package/dist/ids.d.ts +6 -0
- package/dist/ids.js +3 -0
- package/dist/image.d.ts +0 -1
- package/dist/image.js +0 -40
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -5
- package/dist/sandbox.d.ts +0 -1
- package/dist/sandbox.js +0 -6
- package/dist/template-client.d.ts +0 -1
- package/dist/template-client.js +3 -27
- package/dist/transport.d.ts +9 -2
- package/dist/transport.js +64 -102
- package/dist/types.d.ts +19 -7
- package/dist/types.js +0 -7
- package/dist/volume-client.d.ts +0 -1
- package/dist/volume-client.js +0 -17
- package/dist/ws.d.ts +0 -1
- package/dist/ws.js +0 -16
- package/package.json +5 -4
- package/dist/chunked-download.d.ts.map +0 -1
- package/dist/chunked-download.js.map +0 -1
- package/dist/desktop.d.ts.map +0 -1
- package/dist/desktop.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/handle.d.ts.map +0 -1
- package/dist/handle.js.map +0 -1
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js.map +0 -1
- package/dist/image.d.ts.map +0 -1
- package/dist/image.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sandbox.d.ts.map +0 -1
- package/dist/sandbox.js.map +0 -1
- package/dist/template-client.d.ts.map +0 -1
- package/dist/template-client.js.map +0 -1
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/volume-client.d.ts.map +0 -1
- package/dist/volume-client.js.map +0 -1
- package/dist/ws.d.ts.map +0 -1
- package/dist/ws.js.map +0 -1
package/dist/handle.js
CHANGED
|
@@ -1,39 +1,37 @@
|
|
|
1
|
-
// `SessionHandle` — the shared base for {@link Sandbox} and {@link Desktop}.
|
|
2
|
-
//
|
|
3
|
-
// Wraps a {@link ControlChannel} and exposes the CONTRACTS-V2 §4 primitives
|
|
4
|
-
// (commands, pty, runCode, files) plus the gateway-routed lifecycle/admin calls
|
|
5
|
-
// (metrics, snapshot, revert, pause/resume, setTimeout, env, volumes,
|
|
6
|
-
// kill). Both Sandbox and Desktop extend this; Desktop adds the GUI surface.
|
|
7
|
-
//
|
|
8
|
-
// Streaming model: async output from `commands.start`/`pty.create`/`files.watch`
|
|
9
|
-
// arrives as v2 frames `{type:"cmd.data"|"cmd.exit"|"pty.data"|"fs.event", ...}`
|
|
10
|
-
// dispatched by the ControlChannel by `type` + the stream's own id, NOT by the
|
|
11
|
-
// originating request id. We register/unregister those frame handlers as
|
|
12
|
-
// commands/ptys/watches are created and torn down.
|
|
13
1
|
import { downloadAll } from "./chunked-download.js";
|
|
14
|
-
import { GatewayError, SolariError } from "./errors.js";
|
|
2
|
+
import { ActionError, GatewayError, SolariError, TimeoutError } from "./errors.js";
|
|
3
|
+
import { shortId } from "./ids.js";
|
|
15
4
|
import { base64ToBytes, bytesToBase64, ControlChannel, } from "./transport.js";
|
|
16
|
-
|
|
5
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
|
|
6
|
+
const unreleased = new Map();
|
|
7
|
+
const MAX_TRACKED_UNRELEASED = 1000;
|
|
8
|
+
let exitHookInstalled = false;
|
|
9
|
+
function trackUnreleased(token, id) {
|
|
10
|
+
unreleased.set(token, id);
|
|
11
|
+
if (unreleased.size > MAX_TRACKED_UNRELEASED)
|
|
12
|
+
unreleased.delete(unreleased.keys().next().value);
|
|
13
|
+
if (exitHookInstalled || typeof process === "undefined" || typeof process.on !== "function")
|
|
14
|
+
return;
|
|
15
|
+
exitHookInstalled = true;
|
|
16
|
+
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
|
+
}
|
|
21
|
+
unreleased.clear();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const MIN_REST_TIMEOUT_MS = 1000;
|
|
17
25
|
export const PTY_EARLY_BUFFER_MAX = 1024 * 1024;
|
|
18
26
|
export class SessionHandle {
|
|
19
|
-
/** The session id (sandboxId / sessionId). */
|
|
20
27
|
id;
|
|
21
28
|
controlUrl;
|
|
22
29
|
expiresAt;
|
|
23
30
|
channel;
|
|
24
31
|
hooks;
|
|
25
|
-
/** Set by {@link kill} so {@link close} doesn't warn about a machine we
|
|
26
|
-
* have just released. */
|
|
27
32
|
killed = false;
|
|
33
|
+
releaseToken = {};
|
|
28
34
|
constructor(id, controlUrl, expiresAt, config = {}) {
|
|
29
|
-
// A blank id is the one bad value that survives to the wire looking almost
|
|
30
|
-
// right: it builds `/sandboxes//exec` (a double slash), not an obviously
|
|
31
|
-
// wrong path with the word `undefined` in it, so it goes unnoticed while
|
|
32
|
-
// every call the handle makes is malformed. Reject it where it enters.
|
|
33
|
-
// Guard the blank STRING specifically (the sneaky value described above) —
|
|
34
|
-
// a non-string id is either the obvious `undefined`/`null` case this comment
|
|
35
|
-
// excludes or a hand-built bare handle, and calling `.trim()` on it would
|
|
36
|
-
// throw an opaque TypeError instead of this clear message.
|
|
37
35
|
if (typeof id === "string" && id.trim() === "") {
|
|
38
36
|
throw new SolariError("Solari: session id must not be blank — an empty id builds a malformed `/sandboxes//…` request.");
|
|
39
37
|
}
|
|
@@ -42,64 +40,35 @@ export class SessionHandle {
|
|
|
42
40
|
this.expiresAt = expiresAt;
|
|
43
41
|
this.channel = new ControlChannel(controlUrl, config);
|
|
44
42
|
this.hooks = config.hooks ?? {};
|
|
43
|
+
trackUnreleased(this.releaseToken, id);
|
|
45
44
|
}
|
|
46
|
-
/** Whether the control channel is open. */
|
|
47
45
|
get connected() {
|
|
48
46
|
return this.channel.connected;
|
|
49
47
|
}
|
|
50
|
-
/** Subclass hook: send a raw JSON-RPC call over the control channel. */
|
|
51
48
|
rpc(method, params) {
|
|
52
49
|
return this.channel.call(method, params);
|
|
53
50
|
}
|
|
54
|
-
/** Subclass hook: send a v1 streamed call (per-`id` stdout/stderr frames). */
|
|
55
51
|
rpcStream(method, params, onStream) {
|
|
56
52
|
return this.channel.call(method, params, { onStream });
|
|
57
53
|
}
|
|
58
|
-
/** Open the control WebSocket. Idempotent. */
|
|
59
54
|
connect() {
|
|
60
55
|
return this.channel.connect();
|
|
61
56
|
}
|
|
62
|
-
/** Reconnect after a drop (re-opens the socket). */
|
|
63
57
|
reconnect() {
|
|
64
|
-
// No id check here on purpose: reconnect reuses this handle's id, which was
|
|
65
|
-
// already validated as non-blank in the constructor. The two real entrances
|
|
66
|
-
// for an untrusted id are the constructor (create-response wrapping) and
|
|
67
|
-
// the client's connect(id) — not reconnect().
|
|
68
58
|
return this.channel.reconnect();
|
|
69
59
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
*
|
|
75
|
-
* If you meant "I'm done with this machine", call {@link kill}.
|
|
76
|
-
*/
|
|
77
|
-
close() {
|
|
78
|
-
if (!this.killed && typeof console !== "undefined") {
|
|
79
|
-
// Cheap, one-shot nudge: the close/kill distinction is the single most
|
|
80
|
-
// common misread of this API, and silently leaving a machine running is
|
|
81
|
-
// expensive in a way nothing else surfaces.
|
|
82
|
-
console.warn(`[solari] close() on session ${this.id} closed the local channel only — ` +
|
|
60
|
+
close(opts = {}) {
|
|
61
|
+
unreleased.delete(this.releaseToken);
|
|
62
|
+
if (!this.killed && !opts.quiet && typeof console !== "undefined") {
|
|
63
|
+
console.warn(`[solari] close() on session ${shortId(this.id)} closed the local channel only — ` +
|
|
83
64
|
`the remote machine is still running and will bill until its idle ` +
|
|
84
65
|
`timeout. Call kill() to release it now.`);
|
|
85
66
|
}
|
|
86
67
|
this.channel.close();
|
|
87
68
|
}
|
|
88
|
-
// --- commands -------------------------------------------------------------
|
|
89
69
|
commands = {
|
|
90
|
-
/**
|
|
91
|
-
* Run a command to completion. `onStdout`/`onStderr` receive output as it
|
|
92
|
-
* streams (via `cmd.data` frames); the promise resolves with the terminal
|
|
93
|
-
* exit code (from `cmd.exit`).
|
|
94
|
-
*/
|
|
95
70
|
run: (cmd, opts = {}) => this.runCommand(cmd, opts),
|
|
96
|
-
/**
|
|
97
|
-
* Start a command and return a handle immediately (does not wait for exit).
|
|
98
|
-
* Output streams as `cmd.data`; the handle exposes `stdin`, `onData`,
|
|
99
|
-
* `wait()`, and `kill()`.
|
|
100
|
-
*/
|
|
101
71
|
start: (cmd, opts = {}) => this.startCommand(cmd, opts),
|
|
102
|
-
/** Reconnect the control channel (e.g. to resume receiving frames). */
|
|
103
72
|
connect: () => this.channel.reconnect(),
|
|
104
73
|
};
|
|
105
74
|
async startCommand(cmd, opts, acc) {
|
|
@@ -112,10 +81,6 @@ export class SessionHandle {
|
|
|
112
81
|
});
|
|
113
82
|
const cmdId = r.cmdId;
|
|
114
83
|
const dataCbs = [];
|
|
115
|
-
// Frames can arrive in the microtask gap between this method returning the
|
|
116
|
-
// handle and the caller (e.g. runCommand) attaching its onData accumulator.
|
|
117
|
-
// Buffer chunks while no consumer is attached, then replay on first onData
|
|
118
|
-
// so early stdout/stderr is never dropped (would surface as exit=0 + "").
|
|
119
84
|
const buffered = [];
|
|
120
85
|
let exitResolve = null;
|
|
121
86
|
let exitReject = null;
|
|
@@ -123,14 +88,22 @@ export class SessionHandle {
|
|
|
123
88
|
exitResolve = res;
|
|
124
89
|
exitReject = rej;
|
|
125
90
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.channel.onStreamClose(cmdId, (err) =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
91
|
+
let timedOut = false;
|
|
92
|
+
let deadline;
|
|
93
|
+
this.channel.onStreamClose(cmdId, (err) => {
|
|
94
|
+
clearTimeout(deadline);
|
|
95
|
+
exitReject?.(err);
|
|
96
|
+
});
|
|
97
|
+
if (typeof opts.timeoutMs === "number" && opts.timeoutMs > 0) {
|
|
98
|
+
deadline = setTimeout(() => {
|
|
99
|
+
timedOut = true;
|
|
100
|
+
this.channel.call("cmd.kill", { cmdId, signal: 9 }).catch(() => undefined);
|
|
101
|
+
this.channel.offFrame("cmd.data", cmdId);
|
|
102
|
+
this.channel.offFrame("cmd.exit", cmdId);
|
|
103
|
+
this.channel.offStreamClose(cmdId);
|
|
104
|
+
exitResolve?.(-1);
|
|
105
|
+
}, opts.timeoutMs);
|
|
106
|
+
}
|
|
134
107
|
if (acc) {
|
|
135
108
|
dataCbs.push((c) => {
|
|
136
109
|
if (c.stream === "stdout")
|
|
@@ -161,6 +134,7 @@ export class SessionHandle {
|
|
|
161
134
|
this.channel.offFrame("cmd.data", cmdId);
|
|
162
135
|
this.channel.offFrame("cmd.exit", cmdId);
|
|
163
136
|
this.channel.offStreamClose(cmdId);
|
|
137
|
+
clearTimeout(deadline);
|
|
164
138
|
exitResolve?.(typeof f.exitCode === "number" ? f.exitCode : 0);
|
|
165
139
|
});
|
|
166
140
|
return {
|
|
@@ -177,16 +151,15 @@ export class SessionHandle {
|
|
|
177
151
|
dataCbs.push(cb);
|
|
178
152
|
},
|
|
179
153
|
wait: () => exitPromise,
|
|
154
|
+
get timedOut() {
|
|
155
|
+
return timedOut;
|
|
156
|
+
},
|
|
180
157
|
kill: (signal) => this.channel
|
|
181
158
|
.call("cmd.kill", { cmdId, signal })
|
|
182
159
|
.then(() => undefined),
|
|
183
160
|
};
|
|
184
161
|
}
|
|
185
162
|
async runCommand(cmd, opts) {
|
|
186
|
-
// Direct exec means a shell line fails as "no such file or directory",
|
|
187
|
-
// which reads like a missing tool. Let the call run, then explain that
|
|
188
|
-
// specific failure rather than rejecting up front (see
|
|
189
|
-
// explainShellCommandFailure — rejecting would break working callers).
|
|
190
163
|
try {
|
|
191
164
|
return await this.runCommandInner(cmd, opts);
|
|
192
165
|
}
|
|
@@ -195,71 +168,58 @@ export class SessionHandle {
|
|
|
195
168
|
}
|
|
196
169
|
}
|
|
197
170
|
async runCommandInner(cmd, opts) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// "create → run one command" latency, so skipping it is the single biggest
|
|
203
|
-
// TTI win. We only take this path when the control channel isn't already
|
|
204
|
-
// open — if it is, the WS is cheaper and streams. env/user are excluded
|
|
205
|
-
// because the one-shot guest path runs under the session env only; routing
|
|
206
|
-
// those through the streaming path keeps behavior identical.
|
|
171
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
172
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
173
|
+
throw new SolariError(`Solari: timeoutMs must be a non-negative number of milliseconds, got ${String(timeoutMs)}`);
|
|
174
|
+
}
|
|
207
175
|
if (this.hooks.exec &&
|
|
208
176
|
!opts.background &&
|
|
209
177
|
!opts.onStdout &&
|
|
210
178
|
!opts.onStderr &&
|
|
211
179
|
!opts.env &&
|
|
212
180
|
!opts.user &&
|
|
181
|
+
timeoutMs >= MIN_REST_TIMEOUT_MS &&
|
|
182
|
+
timeoutMs <= DEFAULT_COMMAND_TIMEOUT_MS &&
|
|
213
183
|
!this.channel.connected) {
|
|
214
184
|
try {
|
|
215
|
-
|
|
185
|
+
const r = await this.hooks.exec(this.id, {
|
|
216
186
|
cmd,
|
|
217
187
|
args: opts.args,
|
|
218
188
|
cwd: opts.cwd,
|
|
219
|
-
timeoutMs
|
|
189
|
+
timeoutMs,
|
|
220
190
|
});
|
|
191
|
+
return { ...r, timedOut: r.timedOut === true };
|
|
221
192
|
}
|
|
222
193
|
catch (err) {
|
|
223
|
-
// Fall back to the WS path ONLY when the one-shot route is unavailable
|
|
224
|
-
// (an older gateway without /exec). Any other failure — including a
|
|
225
|
-
// real host/command error — propagates, so we never silently
|
|
226
|
-
// double-execute a command that may have already run guest-side.
|
|
227
194
|
if (!isRouteUnavailable(err))
|
|
228
|
-
throw err;
|
|
195
|
+
throw normalizeExecError(err, timeoutMs);
|
|
229
196
|
}
|
|
230
197
|
}
|
|
231
198
|
const acc = { stdout: "", stderr: "" };
|
|
232
|
-
const handle = await this.startCommand(cmd, opts, acc);
|
|
199
|
+
const handle = await this.startCommand(cmd, { ...opts, timeoutMs: undefined }, acc);
|
|
233
200
|
if (opts.background) {
|
|
234
|
-
|
|
235
|
-
return { exitCode: 0, stdout: "", stderr: "" };
|
|
201
|
+
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
|
236
202
|
}
|
|
237
|
-
|
|
238
|
-
// the command is killed and the result is exit -1 with whatever output arrived (the
|
|
239
|
-
// guest's own timeout result). Without this a hung command waited forever here.
|
|
240
|
-
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
|
|
203
|
+
if (timeoutMs > 0) {
|
|
241
204
|
let timer;
|
|
242
205
|
const timedOut = new Promise((resolve) => {
|
|
243
|
-
timer = setTimeout(() => resolve("timeout"),
|
|
206
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
244
207
|
});
|
|
245
|
-
// Keep a late rejection (channel closed after we gave up) from becoming unhandled.
|
|
246
208
|
const exited = handle.wait().then((code) => ({ code }), (err) => ({ err }));
|
|
247
209
|
const first = await Promise.race([exited, timedOut]);
|
|
248
210
|
clearTimeout(timer);
|
|
249
211
|
if (first === "timeout") {
|
|
250
212
|
await handle.kill(9).catch(() => undefined);
|
|
251
|
-
return { exitCode: -1, stdout: acc.stdout, stderr: acc.stderr };
|
|
213
|
+
return { exitCode: -1, stdout: acc.stdout, stderr: acc.stderr, timedOut: true };
|
|
252
214
|
}
|
|
253
215
|
if ("err" in first)
|
|
254
216
|
throw first.err;
|
|
255
|
-
return { exitCode: first.code, stdout: acc.stdout, stderr: acc.stderr };
|
|
217
|
+
return { exitCode: first.code, stdout: acc.stdout, stderr: acc.stderr, timedOut: false };
|
|
256
218
|
}
|
|
257
219
|
const exitCode = await handle.wait();
|
|
258
|
-
return { exitCode, stdout: acc.stdout, stderr: acc.stderr };
|
|
220
|
+
return { exitCode, stdout: acc.stdout, stderr: acc.stderr, timedOut: false };
|
|
259
221
|
}
|
|
260
|
-
// --- pty ------------------------------------------------------------------
|
|
261
222
|
pty = {
|
|
262
|
-
/** Open a PTY; output streams as `pty.data` frames. */
|
|
263
223
|
create: (opts) => this.createPty(opts),
|
|
264
224
|
};
|
|
265
225
|
async createPty(opts) {
|
|
@@ -272,12 +232,6 @@ export class SessionHandle {
|
|
|
272
232
|
});
|
|
273
233
|
const ptyId = r.ptyId;
|
|
274
234
|
const dataCbs = [];
|
|
275
|
-
// Output that arrives before the first onData() (e.g. a shell prompt printed
|
|
276
|
-
// right after pty.create) is held and replayed to the first subscriber, in
|
|
277
|
-
// order, instead of being dropped. The hold is capped at PTY_EARLY_BUFFER_MAX
|
|
278
|
-
// bytes in total: when a PTY nobody subscribes to keeps writing, the OLDEST
|
|
279
|
-
// output is discarded so client memory stays bounded and the newest output is
|
|
280
|
-
// what a late subscriber sees.
|
|
281
235
|
let buffered = [];
|
|
282
236
|
let bufferedBytes = 0;
|
|
283
237
|
this.channel.onFrame("pty.data", ptyId, (f) => {
|
|
@@ -303,8 +257,6 @@ export class SessionHandle {
|
|
|
303
257
|
.call("pty.resize", { ptyId, cols, rows })
|
|
304
258
|
.then(() => undefined),
|
|
305
259
|
onData: (cb) => {
|
|
306
|
-
// Detach the held output BEFORE replaying it, so a callback that throws
|
|
307
|
-
// cannot leave it behind for the next subscriber to receive twice.
|
|
308
260
|
const early = buffered;
|
|
309
261
|
buffered = [];
|
|
310
262
|
bufferedBytes = 0;
|
|
@@ -320,11 +272,6 @@ export class SessionHandle {
|
|
|
320
272
|
},
|
|
321
273
|
};
|
|
322
274
|
}
|
|
323
|
-
// --- runCode --------------------------------------------------------------
|
|
324
|
-
/**
|
|
325
|
-
* Run code in a stateful kernel (`code.run`). Rich outputs (png/html/json/…)
|
|
326
|
-
* come back as `results` items; `onStdout`/`onStderr` receive streamed text.
|
|
327
|
-
*/
|
|
328
275
|
async runCode(code, opts = {}) {
|
|
329
276
|
const r = await this.channel.call("code.run", {
|
|
330
277
|
code,
|
|
@@ -343,12 +290,10 @@ export class SessionHandle {
|
|
|
343
290
|
.map((i) => i.chart);
|
|
344
291
|
return { results, charts, ...(r.error !== undefined ? { error: r.error } : {}) };
|
|
345
292
|
}
|
|
346
|
-
/** Create a fresh stateful kernel context (`code.context.create`). */
|
|
347
293
|
async createCodeContext(language = "python") {
|
|
348
294
|
const r = await this.channel.call("code.context.create", { language });
|
|
349
295
|
return r.contextId;
|
|
350
296
|
}
|
|
351
|
-
// --- files ----------------------------------------------------------------
|
|
352
297
|
files = {
|
|
353
298
|
read: async (path) => {
|
|
354
299
|
const r = await this.channel.call("fs.read", { path });
|
|
@@ -377,15 +322,10 @@ export class SessionHandle {
|
|
|
377
322
|
mkdir: (path) => this.channel
|
|
378
323
|
.call("fs.mkdir", { path })
|
|
379
324
|
.then(() => undefined),
|
|
380
|
-
/** Recursively grep `query` under `path`. */
|
|
381
325
|
search: async (path, query, maxResults) => {
|
|
382
326
|
const r = await this.channel.call("fs.search", { path, query, maxResults });
|
|
383
327
|
return r.matches ?? [];
|
|
384
328
|
},
|
|
385
|
-
/**
|
|
386
|
-
* Watch `path` for changes; `cb` is invoked for each `fs.event` frame.
|
|
387
|
-
* Returns an unwatch function.
|
|
388
|
-
*/
|
|
389
329
|
watch: async (path, cb, recursive = false) => {
|
|
390
330
|
const r = await this.channel.call("fs.watch", {
|
|
391
331
|
path,
|
|
@@ -393,8 +333,6 @@ export class SessionHandle {
|
|
|
393
333
|
});
|
|
394
334
|
const watchId = r.watchId;
|
|
395
335
|
this.channel.onFrame("fs.event", watchId, (f) => {
|
|
396
|
-
// `f.type` is always the frame type ("fs.event"); the change kind
|
|
397
|
-
// (create|write|remove|rename|chmod) is in `f.event`.
|
|
398
336
|
cb({ type: String(f.event ?? "change"), path: String(f.path ?? "") });
|
|
399
337
|
});
|
|
400
338
|
return async () => {
|
|
@@ -402,77 +340,47 @@ export class SessionHandle {
|
|
|
402
340
|
await this.channel.call("fs.unwatch", { watchId });
|
|
403
341
|
};
|
|
404
342
|
},
|
|
405
|
-
/** Upload local bytes to an in-guest path (chunked base64 under the hood). */
|
|
406
343
|
upload: (path, data) => this.channel
|
|
407
344
|
.call("fs.upload", { path, base64: toB64(data) })
|
|
408
345
|
.then(() => undefined),
|
|
409
|
-
/** Download an in-guest file's bytes. */
|
|
410
346
|
download: (path) => downloadAll((method, params) => this.channel.call(method, params), path),
|
|
411
347
|
};
|
|
412
|
-
// --- env ------------------------------------------------------------------
|
|
413
|
-
/** Inject/replace per-session env vars in the guest (`setEnv`). */
|
|
414
348
|
env(vars) {
|
|
415
349
|
return this.channel
|
|
416
350
|
.call("setEnv", { env: vars })
|
|
417
351
|
.then(() => undefined);
|
|
418
352
|
}
|
|
419
|
-
/** Signed, time-limited URL to download an in-guest file directly over HTTP
|
|
420
|
-
* (bypasses the control channel — good for large files). */
|
|
421
353
|
downloadUrl(path) {
|
|
422
354
|
if (!this.hooks.downloadUrl) {
|
|
423
355
|
return Promise.reject(new Error("downloadUrl requires a client-created handle"));
|
|
424
356
|
}
|
|
425
357
|
return this.hooks.downloadUrl(this.id, path);
|
|
426
358
|
}
|
|
427
|
-
/** Signed, time-limited URL to upload a file into the guest directly over
|
|
428
|
-
* HTTP. `path` is the destination (optional; some backends return a prefix). */
|
|
429
359
|
uploadUrl(path) {
|
|
430
360
|
if (!this.hooks.uploadUrl) {
|
|
431
361
|
return Promise.reject(new Error("uploadUrl requires a client-created handle"));
|
|
432
362
|
}
|
|
433
363
|
return this.hooks.uploadUrl(this.id, path);
|
|
434
364
|
}
|
|
435
|
-
// --- volumes --------------------------------------------------------------
|
|
436
365
|
volumes = {
|
|
437
|
-
/**
|
|
438
|
-
* Mount a volume into the guest at `path`. Thin pass-through to the guest
|
|
439
|
-
* `volume.mount` RPC; the gateway volume routes may be a 501 stub today.
|
|
440
|
-
*/
|
|
441
366
|
mount: (volId, path) => this.channel
|
|
442
367
|
.call("volume.mount", { volumeId: volId, path })
|
|
443
368
|
.then(() => undefined),
|
|
444
369
|
};
|
|
445
|
-
// --- git ------------------------------------------------------------------
|
|
446
|
-
// A first-class version-control surface (Daytona-parity). Every method is a
|
|
447
|
-
// safe, non-shell `git` invocation over the existing command RPC (`cmd` +
|
|
448
|
-
// `args`, never a shell string), so there is no injection surface and it runs
|
|
449
|
-
// against any guest that has `git` on PATH — which the base image ships. No
|
|
450
|
-
// guest/gateway change is required.
|
|
451
370
|
git = {
|
|
452
|
-
/** Clone `url` into `opts.path` (or git's default dir) under `opts.cwd`. */
|
|
453
371
|
clone: (url, opts = {}) => this.gitClone(url, opts),
|
|
454
|
-
/** Parsed working-tree status (branch, ahead/behind, staged/modified/untracked). */
|
|
455
372
|
status: (cwd) => this.gitStatus(cwd),
|
|
456
|
-
/** Stage `paths` (use `["."]` for everything). */
|
|
457
373
|
add: (paths, cwd) => this.gitAdd(paths, cwd),
|
|
458
|
-
/** Commit staged changes; returns the new commit hash. */
|
|
459
374
|
commit: (message, opts = {}) => this.gitCommit(message, opts),
|
|
460
|
-
/** Push to a remote (default `origin` + current branch). */
|
|
461
375
|
push: (opts = {}) => this.gitPushPull("push", opts),
|
|
462
|
-
/** Pull from a remote (default `origin` + current branch). */
|
|
463
376
|
pull: (opts = {}) => this.gitPushPull("pull", opts),
|
|
464
|
-
/** Check out an existing ref, or create a branch with `{ create: true }`. */
|
|
465
377
|
checkout: (ref, opts = {}) => this.gitCheckout(ref, opts),
|
|
466
|
-
/** List local branches (name, short commit, whether it's HEAD). */
|
|
467
378
|
branches: (cwd) => this.gitBranches(cwd),
|
|
468
|
-
/** Recent commits, newest first. */
|
|
469
379
|
log: (opts = {}) => this.gitLog(opts),
|
|
470
380
|
};
|
|
471
|
-
/** Run `git <args>` in the guest via the command RPC (no shell). */
|
|
472
381
|
async runGit(args, cwd) {
|
|
473
382
|
return this.runCommand("git", { args, ...(cwd ? { cwd } : {}) });
|
|
474
383
|
}
|
|
475
|
-
/** Run `git <args>`; throw with the stderr on a non-zero exit. */
|
|
476
384
|
async mustGit(args, cwd) {
|
|
477
385
|
const r = await this.runGit(args, cwd);
|
|
478
386
|
if (r.exitCode !== 0) {
|
|
@@ -481,7 +389,6 @@ export class SessionHandle {
|
|
|
481
389
|
}
|
|
482
390
|
return r.stdout;
|
|
483
391
|
}
|
|
484
|
-
/** Splice basic-auth creds into an https remote URL (for private remotes). */
|
|
485
392
|
authUrl(url, username, password) {
|
|
486
393
|
if (!username && !password)
|
|
487
394
|
return url;
|
|
@@ -494,7 +401,7 @@ export class SessionHandle {
|
|
|
494
401
|
return u.toString();
|
|
495
402
|
}
|
|
496
403
|
catch {
|
|
497
|
-
return url;
|
|
404
|
+
return url;
|
|
498
405
|
}
|
|
499
406
|
}
|
|
500
407
|
async gitClone(url, opts) {
|
|
@@ -528,7 +435,6 @@ export class SessionHandle {
|
|
|
528
435
|
st.detached = true;
|
|
529
436
|
}
|
|
530
437
|
else {
|
|
531
|
-
// "main...origin/main [ahead 1, behind 2]" | "main" | "No commits yet on main"
|
|
532
438
|
const noCommits = head.match(/^No commits yet on (.+)$/);
|
|
533
439
|
const body = noCommits?.[1] ?? head;
|
|
534
440
|
st.branch = (body.split("...")[0] ?? "").split(" ")[0] ?? "";
|
|
@@ -568,8 +474,6 @@ export class SessionHandle {
|
|
|
568
474
|
await this.mustGit(["add", "--", ...paths], cwd);
|
|
569
475
|
}
|
|
570
476
|
async gitCommit(message, opts) {
|
|
571
|
-
// `-c user.name/email` scopes identity to this one commit without mutating
|
|
572
|
-
// the repo/global config — handy in an ephemeral sandbox with no git identity.
|
|
573
477
|
const args = [];
|
|
574
478
|
if (opts.author)
|
|
575
479
|
args.push("-c", `user.name=${opts.author}`);
|
|
@@ -584,8 +488,6 @@ export class SessionHandle {
|
|
|
584
488
|
}
|
|
585
489
|
async gitPushPull(op, opts) {
|
|
586
490
|
const remote = opts.remote || "origin";
|
|
587
|
-
// One-off HTTPS auth without persisting a credential: rewrite the remote's
|
|
588
|
-
// URL for just this invocation via `-c url.<authed>.insteadOf=<plain>`.
|
|
589
491
|
const cfg = [];
|
|
590
492
|
if (opts.username || opts.password) {
|
|
591
493
|
const plain = (await this.runGit(["remote", "get-url", remote], opts.cwd)).stdout.trim();
|
|
@@ -608,8 +510,6 @@ export class SessionHandle {
|
|
|
608
510
|
await this.mustGit(args, opts.cwd);
|
|
609
511
|
}
|
|
610
512
|
async gitBranches(cwd) {
|
|
611
|
-
// A stable, parse-friendly format (unit-separated) instead of the porcelain
|
|
612
|
-
// `* main` layout, so branch names with spaces/`*` never confuse the parser.
|
|
613
513
|
const out = await this.mustGit(["branch", "--format=%(HEAD)%1f%(refname:short)%1f%(objectname:short)"], cwd);
|
|
614
514
|
const branches = [];
|
|
615
515
|
for (const line of out.split("\n")) {
|
|
@@ -640,15 +540,12 @@ export class SessionHandle {
|
|
|
640
540
|
}
|
|
641
541
|
return commits;
|
|
642
542
|
}
|
|
643
|
-
// --- lifecycle / admin (gateway-routed via hooks) -------------------------
|
|
644
|
-
/** Live resource metrics. */
|
|
645
543
|
metrics() {
|
|
646
544
|
if (!this.hooks.metrics) {
|
|
647
545
|
return Promise.reject(new Error("metrics requires a client-created handle"));
|
|
648
546
|
}
|
|
649
547
|
return this.hooks.metrics(this.id);
|
|
650
548
|
}
|
|
651
|
-
/** Checkpoint this RUNNING session; it keeps running. Returns the snapshot id. */
|
|
652
549
|
async snapshot(name) {
|
|
653
550
|
if (!this.hooks.snapshot) {
|
|
654
551
|
throw new Error("snapshot requires a client-created handle");
|
|
@@ -656,22 +553,24 @@ export class SessionHandle {
|
|
|
656
553
|
const r = await this.hooks.snapshot(this.id, name);
|
|
657
554
|
return r.snapshotId;
|
|
658
555
|
}
|
|
659
|
-
/** Restore this session in place from a snapshot (id stays stable). */
|
|
660
556
|
revert(snapshotId) {
|
|
661
557
|
if (!this.hooks.revert) {
|
|
662
558
|
return Promise.reject(new Error("revert requires a client-created handle"));
|
|
663
559
|
}
|
|
664
|
-
return this.hooks.revert(this.id, snapshotId)
|
|
560
|
+
return this.hooks.revert(this.id, snapshotId).then(async () => {
|
|
561
|
+
if (this.channel.connected) {
|
|
562
|
+
this.close({ quiet: true });
|
|
563
|
+
await this.channel.connect().catch(() => undefined);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
665
566
|
}
|
|
666
|
-
/** Pause (snapshot RAM+disk, free the slot). Closes the control channel. */
|
|
667
567
|
async pause() {
|
|
668
568
|
if (!this.hooks.pause) {
|
|
669
569
|
throw new Error("pause requires a client-created handle");
|
|
670
570
|
}
|
|
671
571
|
await this.hooks.pause(this.id);
|
|
672
|
-
this.close();
|
|
572
|
+
this.close({ quiet: true });
|
|
673
573
|
}
|
|
674
|
-
/** Resume a paused session; re-points the control channel at the fresh slot. */
|
|
675
574
|
async resume() {
|
|
676
575
|
if (!this.hooks.resume) {
|
|
677
576
|
throw new Error("resume requires a client-created handle");
|
|
@@ -680,17 +579,6 @@ export class SessionHandle {
|
|
|
680
579
|
this.channel.setControlUrl(r.controlUrl);
|
|
681
580
|
await this.channel.reconnect();
|
|
682
581
|
}
|
|
683
|
-
/**
|
|
684
|
-
* Start a **stopped** sandbox (its guest powered itself off) from the disk the
|
|
685
|
-
* host kept, and re-point the control channel at the fresh `controlUrl`.
|
|
686
|
-
*
|
|
687
|
-
* This is a cold boot, not a resume: running processes and memory are gone,
|
|
688
|
-
* files on disk are kept. Use {@link resume} for a paused sandbox. Idempotent:
|
|
689
|
-
* a sandbox that is already running just reconnects. Throws
|
|
690
|
-
* {@link SandboxNotStoppedError} (`409 NotStopped` / `StartInProgress`) for any
|
|
691
|
-
* other state; if the start fails the sandbox stays stopped with its disk, so
|
|
692
|
-
* you can call this again.
|
|
693
|
-
*/
|
|
694
582
|
async start() {
|
|
695
583
|
if (!this.hooks.start) {
|
|
696
584
|
throw new Error("start requires a client-created handle");
|
|
@@ -699,79 +587,50 @@ export class SessionHandle {
|
|
|
699
587
|
this.channel.setControlUrl(r.controlUrl);
|
|
700
588
|
await this.channel.reconnect();
|
|
701
589
|
}
|
|
702
|
-
/**
|
|
703
|
-
* Park (deschedule) this session: freeze the guest's vCPUs while leaving its
|
|
704
|
-
* RAM resident on the same host. Milliseconds to park, milliseconds to wake.
|
|
705
|
-
*
|
|
706
|
-
* Unlike {@link pause}, this does NOT close the control channel and does not
|
|
707
|
-
* free the slot -- the session keeps its host, its ports and its routing, so
|
|
708
|
-
* anything already pointing at it keeps working. There is no need to call
|
|
709
|
-
* {@link unpark} first: exec, the control/stream channels, port preview and
|
|
710
|
-
* signed file reads all wake a parked session automatically.
|
|
711
|
-
*
|
|
712
|
-
* Use it around a long LLM or tool call to stop paying for vCPU you are not
|
|
713
|
-
* using. For hands-off behaviour set `lifecycle.parkAfterMs` at create time
|
|
714
|
-
* instead and the gateway parks on an idle timer.
|
|
715
|
-
*/
|
|
716
590
|
async park() {
|
|
717
591
|
if (!this.hooks.park) {
|
|
718
592
|
throw new Error("park requires a client-created handle");
|
|
719
593
|
}
|
|
720
594
|
await this.hooks.park(this.id);
|
|
721
595
|
}
|
|
722
|
-
/**
|
|
723
|
-
* Wake a parked session explicitly. Rarely needed -- every normal request
|
|
724
|
-
* path unparks on touch -- but lets a caller pay the (millisecond) wake up
|
|
725
|
-
* front rather than on the next real request.
|
|
726
|
-
*/
|
|
727
596
|
async unpark() {
|
|
728
597
|
if (!this.hooks.unpark) {
|
|
729
598
|
throw new Error("unpark requires a client-created handle");
|
|
730
599
|
}
|
|
731
600
|
await this.hooks.unpark(this.id);
|
|
732
601
|
}
|
|
733
|
-
/** Extend the idle/keep-alive window. */
|
|
734
602
|
setTimeout(timeoutMs) {
|
|
735
603
|
if (!this.hooks.setTimeout) {
|
|
736
604
|
return Promise.reject(new Error("setTimeout requires a client-created handle"));
|
|
737
605
|
}
|
|
738
606
|
return this.hooks.setTimeout(this.id, timeoutMs);
|
|
739
607
|
}
|
|
740
|
-
/**
|
|
741
|
-
* Resolve a public preview URL for an in-guest port (e.g. a dev server on
|
|
742
|
-
* `:3000`). Returns the routable `https://` URL and, when the gateway signs
|
|
743
|
-
* one, an access `token`.
|
|
744
|
-
*/
|
|
745
608
|
previewUrl(port) {
|
|
746
609
|
if (!this.hooks.previewUrl) {
|
|
747
610
|
return Promise.reject(new Error("previewUrl requires a client-created handle"));
|
|
748
611
|
}
|
|
749
612
|
return this.hooks.previewUrl(this.id, port);
|
|
750
613
|
}
|
|
751
|
-
|
|
614
|
+
async [Symbol.asyncDispose ?? Symbol.for("Symbol.asyncDispose")]() {
|
|
615
|
+
await this.kill();
|
|
616
|
+
}
|
|
752
617
|
async kill() {
|
|
753
618
|
if (this.hooks.kill)
|
|
754
619
|
await this.hooks.kill(this.id);
|
|
755
|
-
// Mark first: close() warns about a still-running machine, which is exactly
|
|
756
|
-
// wrong once we have just released it.
|
|
757
620
|
this.killed = true;
|
|
758
621
|
this.close();
|
|
759
622
|
}
|
|
760
623
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
* for anyone doing it today, and the complaint was never that the call was
|
|
772
|
-
* accepted — it was that the failure did not say why. So let it run, and if it
|
|
773
|
-
* fails the way a shell line fails, explain it.
|
|
774
|
-
*/
|
|
624
|
+
function normalizeExecError(err, timeoutMs) {
|
|
625
|
+
if (err instanceof GatewayError) {
|
|
626
|
+
if (err.status === 400 && err.code === "ExecFailed")
|
|
627
|
+
return new ActionError("cmd.start", err.message, err.code);
|
|
628
|
+
if (err.status === 502 && /i\/o timeout/.test(err.message)) {
|
|
629
|
+
return new TimeoutError("cmd.run", timeoutMs);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return err;
|
|
633
|
+
}
|
|
775
634
|
function explainShellCommandFailure(cmd, err) {
|
|
776
635
|
const msg = err instanceof Error ? err.message : String(err);
|
|
777
636
|
if (!/no such file or directory|ENOENT|not found/i.test(msg))
|
|
@@ -780,20 +639,19 @@ function explainShellCommandFailure(cmd, err) {
|
|
|
780
639
|
if (!/\s/.test(trimmed))
|
|
781
640
|
return err;
|
|
782
641
|
const [head = trimmed, ...rest] = trimmed.split(/\s+/);
|
|
642
|
+
const viaShell = `run("sh", { args: ["-lc", ${JSON.stringify(trimmed)}] })`;
|
|
643
|
+
const split = /['"\\$`|&;<>*?()]/.test(trimmed)
|
|
644
|
+
? ""
|
|
645
|
+
: `Pass the arguments separately: run(${JSON.stringify(head)}, { args: ${JSON.stringify(rest)} }), or `;
|
|
783
646
|
const hint = ` — commands.run() executes the binary directly and does not interpret a ` +
|
|
784
647
|
`shell line, so ${JSON.stringify(cmd)} was looked up as a single filename. ` +
|
|
785
|
-
|
|
786
|
-
`${JSON.stringify(rest)} }), or run it through a shell explicitly: ` +
|
|
787
|
-
`run("sh", { args: ["-lc", ${JSON.stringify(trimmed)}] }).`;
|
|
648
|
+
`${split}${split ? "run" : "Run"} it through a shell explicitly: ${viaShell}.`;
|
|
788
649
|
if (err instanceof Error) {
|
|
789
650
|
err.message += hint;
|
|
790
651
|
return err;
|
|
791
652
|
}
|
|
792
653
|
return new Error(msg + hint);
|
|
793
654
|
}
|
|
794
|
-
/** True when an error means the one-shot `/exec` route isn't served (an older
|
|
795
|
-
* gateway) — the only case where `runCommand` retries over the control WS. A
|
|
796
|
-
* real host/command failure is NOT route-unavailable and must propagate. */
|
|
797
655
|
function isRouteUnavailable(err) {
|
|
798
656
|
return (err instanceof GatewayError &&
|
|
799
657
|
(err.status === 404 || err.status === 405 || err.status === 501));
|
|
@@ -802,4 +660,3 @@ function toB64(data) {
|
|
|
802
660
|
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
803
661
|
return bytesToBase64(bytes);
|
|
804
662
|
}
|
|
805
|
-
//# sourceMappingURL=handle.js.map
|