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