@drej/core 0.2.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/dist/index.d.mts +497 -0
- package/dist/index.mjs +603 -0
- package/package.json +20 -7
- package/dist/errors.d.ts +0 -31
- package/dist/index.d.ts +0 -10
- package/dist/ledger.d.ts +0 -67
- package/dist/logger.d.ts +0 -23
- package/dist/steps.d.ts +0 -100
- package/dist/validate.d.ts +0 -2
- package/dist/workflow.d.ts +0 -99
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { ExecClient, SSEEventType, SnapshotState } from "@drej/opensandbox";
|
|
2
|
+
//#region src/ledger.ts
|
|
3
|
+
/** Status of a sandbox session derived from its ledger events. */
|
|
4
|
+
let SandboxStatus = /* @__PURE__ */ function(SandboxStatus) {
|
|
5
|
+
SandboxStatus["Running"] = "running";
|
|
6
|
+
SandboxStatus["Completed"] = "completed";
|
|
7
|
+
return SandboxStatus;
|
|
8
|
+
}({});
|
|
9
|
+
/** Events emitted during execution and stored in the ledger. */
|
|
10
|
+
let LedgerEvent = /* @__PURE__ */ function(LedgerEvent) {
|
|
11
|
+
/** Emitted when a sandbox is created and reaches Running state. */
|
|
12
|
+
LedgerEvent["SandboxCreated"] = "sandbox_created";
|
|
13
|
+
/** Emitted at the start of each exec() or execCode() call. */
|
|
14
|
+
LedgerEvent["ExecStart"] = "exec_start";
|
|
15
|
+
/** Streaming output chunk from exec() or execCode(). */
|
|
16
|
+
LedgerEvent["ExecEvent"] = "exec_event";
|
|
17
|
+
/** Emitted when an exec() or execCode() call completes. */
|
|
18
|
+
LedgerEvent["ExecComplete"] = "exec_complete";
|
|
19
|
+
/** Emitted when checkpoint() captures a snapshot. */
|
|
20
|
+
LedgerEvent["CheckpointCreated"] = "checkpoint_created";
|
|
21
|
+
/** Emitted when a sandbox is closed. */
|
|
22
|
+
LedgerEvent["SandboxClosed"] = "sandbox_closed";
|
|
23
|
+
/** Emitted once when a workflow run starts, before any steps execute. */
|
|
24
|
+
LedgerEvent["RunStarted"] = "run_started";
|
|
25
|
+
/** Emitted at the beginning of each step. */
|
|
26
|
+
LedgerEvent["StepStart"] = "step_start";
|
|
27
|
+
/** Emitted when a step finishes successfully. */
|
|
28
|
+
LedgerEvent["StepComplete"] = "step_complete";
|
|
29
|
+
/** Emitted when a step throws an unrecoverable error. */
|
|
30
|
+
LedgerEvent["StepFailed"] = "step_failed";
|
|
31
|
+
/** Emitted when a step's rollback handler completes during saga compensation. */
|
|
32
|
+
LedgerEvent["StepRolledBack"] = "step_rolled_back";
|
|
33
|
+
/** Emitted after all steps finish without error. */
|
|
34
|
+
LedgerEvent["WorkflowComplete"] = "workflow_complete";
|
|
35
|
+
/** Emitted after rollback completes following a step failure. */
|
|
36
|
+
LedgerEvent["WorkflowFailed"] = "workflow_failed";
|
|
37
|
+
/** Durable resumption point written after each successful step. */
|
|
38
|
+
LedgerEvent["Checkpoint"] = "checkpoint";
|
|
39
|
+
/** Emitted when a sandbox snapshot is captured mid-run. */
|
|
40
|
+
LedgerEvent["Snapshot"] = "snapshot";
|
|
41
|
+
return LedgerEvent;
|
|
42
|
+
}({});
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/logger.ts
|
|
45
|
+
let LogLevel = /* @__PURE__ */ function(LogLevel) {
|
|
46
|
+
LogLevel[LogLevel["Debug"] = 0] = "Debug";
|
|
47
|
+
LogLevel[LogLevel["Info"] = 1] = "Info";
|
|
48
|
+
LogLevel[LogLevel["Warn"] = 2] = "Warn";
|
|
49
|
+
LogLevel[LogLevel["Error"] = 3] = "Error";
|
|
50
|
+
LogLevel[LogLevel["Silent"] = 4] = "Silent";
|
|
51
|
+
return LogLevel;
|
|
52
|
+
}({});
|
|
53
|
+
var ConsoleLogger = class {
|
|
54
|
+
minLevel;
|
|
55
|
+
constructor(minLevel = 1) {
|
|
56
|
+
this.minLevel = minLevel;
|
|
57
|
+
}
|
|
58
|
+
log(level, prefix, msg, meta) {
|
|
59
|
+
if (level < this.minLevel) return;
|
|
60
|
+
const out = meta ? `${prefix} ${msg} ${JSON.stringify(meta)}` : `${prefix} ${msg}`;
|
|
61
|
+
if (level >= 3) console.error(out);
|
|
62
|
+
else console.log(out);
|
|
63
|
+
}
|
|
64
|
+
debug(msg, meta) {
|
|
65
|
+
this.log(0, "[DEBUG]", msg, meta);
|
|
66
|
+
}
|
|
67
|
+
info(msg, meta) {
|
|
68
|
+
this.log(1, "[INFO]", msg, meta);
|
|
69
|
+
}
|
|
70
|
+
warn(msg, meta) {
|
|
71
|
+
this.log(2, "[WARN]", msg, meta);
|
|
72
|
+
}
|
|
73
|
+
error(msg, meta) {
|
|
74
|
+
this.log(3, "[ERROR]", msg, meta);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const noopLogger = {
|
|
78
|
+
debug: () => {},
|
|
79
|
+
info: () => {},
|
|
80
|
+
warn: () => {},
|
|
81
|
+
error: () => {}
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/errors.ts
|
|
85
|
+
/** Base class for all drej workflow runtime errors. */
|
|
86
|
+
var WorkflowError = class extends Error {
|
|
87
|
+
constructor(message) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "WorkflowError";
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/** Thrown when a sandbox fails to create, boot, or reach `Running` state. */
|
|
93
|
+
var SandboxError = class extends WorkflowError {
|
|
94
|
+
sandboxId;
|
|
95
|
+
constructor(message, sandboxId) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.sandboxId = sandboxId;
|
|
98
|
+
this.name = "SandboxError";
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Thrown when execd inside a sandbox never becomes ready within the retry window.
|
|
103
|
+
* The sandbox is `Running` from the control plane's perspective, but the exec
|
|
104
|
+
* daemon is not accepting connections.
|
|
105
|
+
*/
|
|
106
|
+
var ExecConnectionError = class extends WorkflowError {
|
|
107
|
+
sandboxId;
|
|
108
|
+
constructor(sandboxId) {
|
|
109
|
+
super(`execd not ready for sandbox ${sandboxId}`);
|
|
110
|
+
this.sandboxId = sandboxId;
|
|
111
|
+
this.name = "ExecConnectionError";
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Thrown when an `exec()` step exits with a non-zero exit code and the
|
|
116
|
+
* `strict` option is enabled. Carries the exit code and original command.
|
|
117
|
+
*/
|
|
118
|
+
var CommandError = class extends WorkflowError {
|
|
119
|
+
exitCode;
|
|
120
|
+
command;
|
|
121
|
+
sandboxId;
|
|
122
|
+
constructor(exitCode, command, sandboxId) {
|
|
123
|
+
super(`Command exited with code ${exitCode}: ${command}`);
|
|
124
|
+
this.exitCode = exitCode;
|
|
125
|
+
this.command = command;
|
|
126
|
+
this.sandboxId = sandboxId;
|
|
127
|
+
this.name = "CommandError";
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Thrown when a step exceeds its `timeoutMs` limit (either set per-step or
|
|
132
|
+
* via `RunOptions.stepTimeoutMs`). The workflow will roll back after this error.
|
|
133
|
+
*/
|
|
134
|
+
var StepTimeoutError = class extends WorkflowError {
|
|
135
|
+
stepId;
|
|
136
|
+
timeoutMs;
|
|
137
|
+
constructor(stepId, timeoutMs) {
|
|
138
|
+
super(`Step "${stepId}" timed out after ${timeoutMs}ms`);
|
|
139
|
+
this.stepId = stepId;
|
|
140
|
+
this.timeoutMs = timeoutMs;
|
|
141
|
+
this.name = "StepTimeoutError";
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/exec-handle.ts
|
|
146
|
+
/**
|
|
147
|
+
* Returned by `Sandbox.exec()` and `Sandbox.execCode()`.
|
|
148
|
+
*
|
|
149
|
+
* Implements `PromiseLike<ExecResult>` so `await sb.exec(...)` works naturally.
|
|
150
|
+
* Also exposes `pipe()`, `stdout()`, and `result()` for streaming output.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* // simple await
|
|
155
|
+
* const { exitCode } = await sb.exec("npm test");
|
|
156
|
+
*
|
|
157
|
+
* // stream to stdout
|
|
158
|
+
* await sb.exec("npm run build").pipe(process.stdout);
|
|
159
|
+
*
|
|
160
|
+
* // async generator
|
|
161
|
+
* for await (const chunk of sb.exec("npm test").stdout()) process.stdout.write(chunk);
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
var ExecHandle = class {
|
|
165
|
+
_promise;
|
|
166
|
+
_chunks = [];
|
|
167
|
+
_wakeup = null;
|
|
168
|
+
_done = false;
|
|
169
|
+
_err;
|
|
170
|
+
_hasErr = false;
|
|
171
|
+
constructor(driver) {
|
|
172
|
+
if (driver.type === "replay") {
|
|
173
|
+
if (driver.result.stdout) this._chunks.push(driver.result.stdout);
|
|
174
|
+
this._done = true;
|
|
175
|
+
this._promise = Promise.resolve(driver.result);
|
|
176
|
+
} else {
|
|
177
|
+
this._promise = this._drain(driver.gen, driver.onDone);
|
|
178
|
+
this._promise.catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async _drain(gen, onDone) {
|
|
182
|
+
const stderr = [];
|
|
183
|
+
let exitCode = 0;
|
|
184
|
+
try {
|
|
185
|
+
for await (const ev of gen) {
|
|
186
|
+
if (ev.type === SSEEventType.Stdout && ev.text) {
|
|
187
|
+
this._chunks.push(ev.text);
|
|
188
|
+
this._notify();
|
|
189
|
+
}
|
|
190
|
+
if (ev.type === SSEEventType.Stderr && ev.text) stderr.push(ev.text);
|
|
191
|
+
if (ev.type === SSEEventType.Error && ev.error?.evalue !== void 0) {
|
|
192
|
+
const code = Number(ev.error.evalue);
|
|
193
|
+
if (!isNaN(code)) exitCode = code;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
this._err = err;
|
|
198
|
+
this._hasErr = true;
|
|
199
|
+
throw err;
|
|
200
|
+
} finally {
|
|
201
|
+
this._done = true;
|
|
202
|
+
this._notify();
|
|
203
|
+
}
|
|
204
|
+
const result = {
|
|
205
|
+
stdout: this._chunks.join(""),
|
|
206
|
+
stderr: stderr.join(""),
|
|
207
|
+
exitCode
|
|
208
|
+
};
|
|
209
|
+
await onDone(result);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
_notify() {
|
|
213
|
+
const fn = this._wakeup;
|
|
214
|
+
this._wakeup = null;
|
|
215
|
+
fn?.();
|
|
216
|
+
}
|
|
217
|
+
then(onfulfilled, onrejected) {
|
|
218
|
+
return this._promise.then(onfulfilled, onrejected);
|
|
219
|
+
}
|
|
220
|
+
/** Resolve the full result: `{ stdout, stderr, exitCode }`. */
|
|
221
|
+
result() {
|
|
222
|
+
return this._promise;
|
|
223
|
+
}
|
|
224
|
+
/** Async generator yielding stdout chunks as they arrive. */
|
|
225
|
+
async *stdout() {
|
|
226
|
+
let pos = 0;
|
|
227
|
+
while (true) {
|
|
228
|
+
while (pos < this._chunks.length) yield this._chunks[pos++];
|
|
229
|
+
if (this._done) break;
|
|
230
|
+
await new Promise((r) => {
|
|
231
|
+
this._wakeup = r;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
while (pos < this._chunks.length) yield this._chunks[pos++];
|
|
235
|
+
if (this._hasErr) throw this._err;
|
|
236
|
+
}
|
|
237
|
+
/** Pipe stdout to any writable with a `write(chunk: string)` method. */
|
|
238
|
+
async pipe(writable) {
|
|
239
|
+
for await (const chunk of this.stdout()) writable.write(chunk);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/sandbox.ts
|
|
244
|
+
/**
|
|
245
|
+
* Resolve an ExecClient for a sandbox. Calls getEndpoint once (each call
|
|
246
|
+
* returns a different ephemeral proxy port) then polls listContexts until
|
|
247
|
+
* execd is ready to accept connections.
|
|
248
|
+
*/
|
|
249
|
+
async function resolveExecClient(control, sandboxId, useServerProxy, retries = 15, delayMs = 1e3) {
|
|
250
|
+
const ep = await control.getEndpoint(sandboxId, 44772, useServerProxy);
|
|
251
|
+
const client = new ExecClient({
|
|
252
|
+
baseUrl: ep.endpoint.startsWith("http") ? ep.endpoint : `http://${ep.endpoint}`,
|
|
253
|
+
accessToken: ep.headers?.["X-EXECD-ACCESS-TOKEN"] ?? ""
|
|
254
|
+
});
|
|
255
|
+
for (let attempt = 0; attempt <= retries; attempt++) try {
|
|
256
|
+
await client.listContexts();
|
|
257
|
+
return client;
|
|
258
|
+
} catch {
|
|
259
|
+
if (attempt === retries) throw new ExecConnectionError(sandboxId);
|
|
260
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
261
|
+
}
|
|
262
|
+
throw new Error("unreachable");
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A live sandbox container. Returned by `DrejClient.sandbox()` and `DrejClient.resume()`.
|
|
266
|
+
*
|
|
267
|
+
* Call `exec()` to run commands, `checkpoint()` to snapshot state, and `close()`
|
|
268
|
+
* when done. Multiple sandboxes can be held simultaneously — just assign to
|
|
269
|
+
* different variables.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* ```ts
|
|
273
|
+
* const sb = await client.sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } });
|
|
274
|
+
* await sb.exec("npm ci");
|
|
275
|
+
* await sb.checkpoint();
|
|
276
|
+
* await sb.exec("npm test").pipe(process.stdout);
|
|
277
|
+
* await sb.close();
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
var Sandbox = class {
|
|
281
|
+
/** OpenSandbox container ID — also the unique ledger key for this session. */
|
|
282
|
+
sandboxId;
|
|
283
|
+
/** User-provided name (or auto-generated). */
|
|
284
|
+
name;
|
|
285
|
+
_deps;
|
|
286
|
+
/** Cached exec results for replay mode (populated by DrejClient.resume()). */
|
|
287
|
+
_replayCache;
|
|
288
|
+
_execClient = null;
|
|
289
|
+
_seq = 0;
|
|
290
|
+
_closed = false;
|
|
291
|
+
constructor(sandboxId, name, deps, replayCache = /* @__PURE__ */ new Map()) {
|
|
292
|
+
this.sandboxId = sandboxId;
|
|
293
|
+
this.name = name;
|
|
294
|
+
this._deps = deps;
|
|
295
|
+
this._replayCache = replayCache;
|
|
296
|
+
}
|
|
297
|
+
async _getExecClient() {
|
|
298
|
+
if (!this._execClient) this._execClient = await resolveExecClient(this._deps.control, this.sandboxId, this._deps.useServerProxy);
|
|
299
|
+
return this._execClient;
|
|
300
|
+
}
|
|
301
|
+
async _emit(event, stepIndex, payload) {
|
|
302
|
+
await this._deps.adapter.append({
|
|
303
|
+
ts: Date.now(),
|
|
304
|
+
name: this.name,
|
|
305
|
+
sandboxId: this.sandboxId,
|
|
306
|
+
stepIndex,
|
|
307
|
+
event,
|
|
308
|
+
payload
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Execute a shell command inside the sandbox.
|
|
313
|
+
*
|
|
314
|
+
* Returns an `ExecHandle` — await it for the result, call `.pipe()` to stream
|
|
315
|
+
* stdout, or use `.stdout()` as an async generator. Every call is logged to
|
|
316
|
+
* the ledger; replayed execs in a resumed sandbox return cached output
|
|
317
|
+
* instantly without re-running.
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```ts
|
|
321
|
+
* const { exitCode } = await sb.exec("npm test");
|
|
322
|
+
* await sb.exec("npm run build").pipe(process.stdout);
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
exec(cmd, opts = {}) {
|
|
326
|
+
const seq = ++this._seq;
|
|
327
|
+
if (this._replayCache.has(seq)) return new ExecHandle({
|
|
328
|
+
type: "replay",
|
|
329
|
+
result: this._replayCache.get(seq)
|
|
330
|
+
});
|
|
331
|
+
const self = this;
|
|
332
|
+
async function* stream() {
|
|
333
|
+
const execClient = await self._getExecClient();
|
|
334
|
+
await self._emit("exec_start", seq, {
|
|
335
|
+
cmd,
|
|
336
|
+
seq
|
|
337
|
+
});
|
|
338
|
+
self._deps.hooks?.onExecStart?.(self.sandboxId, seq, cmd);
|
|
339
|
+
const sh = opts.shell ?? self._deps.shell ?? "/bin/sh";
|
|
340
|
+
const command = `echo ${Buffer.from(cmd).toString("base64")} | base64 -d | ${sh}`;
|
|
341
|
+
for await (const ev of execClient.executeCommand({
|
|
342
|
+
command,
|
|
343
|
+
cwd: opts.cwd,
|
|
344
|
+
envs: opts.env,
|
|
345
|
+
timeout: opts.timeoutMs
|
|
346
|
+
})) {
|
|
347
|
+
await self._emit("exec_event", seq, {
|
|
348
|
+
seq,
|
|
349
|
+
...ev
|
|
350
|
+
});
|
|
351
|
+
yield ev;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return new ExecHandle({
|
|
355
|
+
type: "stream",
|
|
356
|
+
gen: stream(),
|
|
357
|
+
onDone: async (result) => {
|
|
358
|
+
await self._emit("exec_complete", seq, {
|
|
359
|
+
exitCode: result.exitCode,
|
|
360
|
+
seq
|
|
361
|
+
});
|
|
362
|
+
self._deps.hooks?.onExecComplete?.(self.sandboxId, seq, result);
|
|
363
|
+
if (opts.strict !== false && result.exitCode !== 0) throw new CommandError(result.exitCode, cmd, self.sandboxId);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Create a code execution context for use with `execCode()`.
|
|
369
|
+
*
|
|
370
|
+
* The code interpreter requires a context for every `execCode()` call. Call
|
|
371
|
+
* this once per session (or once per isolated scope), then pass the returned
|
|
372
|
+
* object as `opts.context` to `execCode()`. Variables defined in one call
|
|
373
|
+
* are visible to subsequent calls sharing the same context.
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```ts
|
|
377
|
+
* const ctx = await sb.createCodeContext(CodeLanguage.Python);
|
|
378
|
+
* await sb.execCode('x = 42', { context: ctx });
|
|
379
|
+
* await sb.execCode('print(x)', { context: ctx }); // prints 42
|
|
380
|
+
* ```
|
|
381
|
+
*/
|
|
382
|
+
async createCodeContext(language) {
|
|
383
|
+
return (await this._getExecClient()).createContext(language);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Execute code via the sandbox's code interpreter (Python, JS, etc.).
|
|
387
|
+
*
|
|
388
|
+
* Uses the execd `/code` endpoint for stateful, Jupyter-style execution.
|
|
389
|
+
* Contexts persist across calls — use `opts.context` to target a specific one.
|
|
390
|
+
* Create a context first with `sb.createCodeContext(language)`.
|
|
391
|
+
*/
|
|
392
|
+
execCode(code, opts = {}) {
|
|
393
|
+
const seq = ++this._seq;
|
|
394
|
+
if (this._replayCache.has(seq)) return new ExecHandle({
|
|
395
|
+
type: "replay",
|
|
396
|
+
result: this._replayCache.get(seq)
|
|
397
|
+
});
|
|
398
|
+
const self = this;
|
|
399
|
+
async function* stream() {
|
|
400
|
+
const execClient = await self._getExecClient();
|
|
401
|
+
await self._emit("exec_start", seq, {
|
|
402
|
+
code,
|
|
403
|
+
seq
|
|
404
|
+
});
|
|
405
|
+
for await (const ev of execClient.executeCode({
|
|
406
|
+
code,
|
|
407
|
+
context: opts.context
|
|
408
|
+
})) {
|
|
409
|
+
await self._emit("exec_event", seq, {
|
|
410
|
+
seq,
|
|
411
|
+
...ev
|
|
412
|
+
});
|
|
413
|
+
yield ev;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return new ExecHandle({
|
|
417
|
+
type: "stream",
|
|
418
|
+
gen: stream(),
|
|
419
|
+
onDone: async (result) => {
|
|
420
|
+
await self._emit("exec_complete", seq, {
|
|
421
|
+
exitCode: result.exitCode,
|
|
422
|
+
seq
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/** Write a file into the sandbox. */
|
|
428
|
+
async writeFile(path, content) {
|
|
429
|
+
await (await this._getExecClient()).uploadFile(path, content);
|
|
430
|
+
}
|
|
431
|
+
/** Read a file from the sandbox as a UTF-8 string. */
|
|
432
|
+
async readFile(path) {
|
|
433
|
+
const reader = (await (await this._getExecClient()).downloadFile(path)).getReader();
|
|
434
|
+
const chunks = [];
|
|
435
|
+
while (true) {
|
|
436
|
+
const { done, value } = await reader.read();
|
|
437
|
+
if (done) break;
|
|
438
|
+
if (value) chunks.push(value);
|
|
439
|
+
}
|
|
440
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
441
|
+
const merged = new Uint8Array(total);
|
|
442
|
+
let offset = 0;
|
|
443
|
+
for (const chunk of chunks) {
|
|
444
|
+
merged.set(chunk, offset);
|
|
445
|
+
offset += chunk.length;
|
|
446
|
+
}
|
|
447
|
+
return new TextDecoder().decode(merged);
|
|
448
|
+
}
|
|
449
|
+
/** Delete a file from the sandbox. */
|
|
450
|
+
async deleteFile(path) {
|
|
451
|
+
await (await this._getExecClient()).deleteFile(path);
|
|
452
|
+
}
|
|
453
|
+
/** Move or rename a file inside the sandbox. */
|
|
454
|
+
async moveFile(from, to) {
|
|
455
|
+
await (await this._getExecClient()).moveFile(from, to);
|
|
456
|
+
}
|
|
457
|
+
/** List files in a directory inside the sandbox. */
|
|
458
|
+
async listDirectory(path, opts = {}) {
|
|
459
|
+
return (await this._getExecClient()).listDirectory(path, opts.depth);
|
|
460
|
+
}
|
|
461
|
+
/** Search for files matching a glob pattern inside the sandbox. */
|
|
462
|
+
async searchFiles(pattern, path = "/") {
|
|
463
|
+
return (await this._getExecClient()).searchFiles(pattern, path);
|
|
464
|
+
}
|
|
465
|
+
/** Create a directory (and parents) inside the sandbox. */
|
|
466
|
+
async createDirectory(path) {
|
|
467
|
+
await (await this._getExecClient()).createDirectory(path);
|
|
468
|
+
}
|
|
469
|
+
/** Delete a directory inside the sandbox. */
|
|
470
|
+
async deleteDirectory(path) {
|
|
471
|
+
await (await this._getExecClient()).deleteDirectory(path);
|
|
472
|
+
}
|
|
473
|
+
/** Return metadata for a file or directory (size, type, mode, timestamps). */
|
|
474
|
+
async getFileInfo(path) {
|
|
475
|
+
return (await this._getExecClient()).getFileInfo(path);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Replace substrings in one or more files inside the sandbox.
|
|
479
|
+
*
|
|
480
|
+
* More efficient than `readFile` → string replace → `writeFile` for targeted edits.
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* ```ts
|
|
484
|
+
* await sb.replaceInFiles([{ path: "/app/config.json", old: "localhost", new: "0.0.0.0" }]);
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
async replaceInFiles(replacements) {
|
|
488
|
+
await (await this._getExecClient()).replaceInFiles(replacements);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Copy a file from this sandbox into another sandbox.
|
|
492
|
+
*
|
|
493
|
+
* Reads the file as a UTF-8 string and writes it to the same path on the target.
|
|
494
|
+
* Use this to move results between a fork and its origin, or between parallel sandboxes.
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* ```ts
|
|
498
|
+
* await sb.transfer("/app/output.json", fork);
|
|
499
|
+
* ```
|
|
500
|
+
*/
|
|
501
|
+
async transfer(path, target) {
|
|
502
|
+
const content = await this.readFile(path);
|
|
503
|
+
await target.writeFile(path, content);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Return a proxied URL and auth headers for a port inside the sandbox.
|
|
507
|
+
*
|
|
508
|
+
* Use this to send HTTP requests to a server running inside the sandbox.
|
|
509
|
+
*
|
|
510
|
+
* @example
|
|
511
|
+
* ```ts
|
|
512
|
+
* await sb.exec("node server.js &");
|
|
513
|
+
* const { url, headers } = await sb.proxy(3000);
|
|
514
|
+
* const res = await fetch(`${url}/health`, { headers });
|
|
515
|
+
* ```
|
|
516
|
+
*/
|
|
517
|
+
async proxy(port) {
|
|
518
|
+
const ep = await this._deps.control.getEndpoint(this.sandboxId, port, this._deps.useServerProxy);
|
|
519
|
+
return {
|
|
520
|
+
url: ep.endpoint.startsWith("http") ? ep.endpoint : `http://${ep.endpoint}`,
|
|
521
|
+
headers: ep.headers ?? {}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/** Return current CPU and memory usage for this sandbox. */
|
|
525
|
+
async metrics() {
|
|
526
|
+
return (await this._getExecClient()).getMetrics();
|
|
527
|
+
}
|
|
528
|
+
/** Return all checkpoints for this sandbox in creation order. */
|
|
529
|
+
listCheckpoints() {
|
|
530
|
+
return this._deps.adapter.listCheckpoints(this.name, this.sandboxId);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Snapshot the current sandbox and return a new independent `Sandbox` from that state.
|
|
534
|
+
*
|
|
535
|
+
* The original sandbox keeps running. Both operate on separate containers restored
|
|
536
|
+
* from the same snapshot. Equivalent to `checkpoint()` followed by `resume()` on a
|
|
537
|
+
* clone, but without closing the original.
|
|
538
|
+
*
|
|
539
|
+
* @example
|
|
540
|
+
* ```ts
|
|
541
|
+
* await sb.exec("npm ci");
|
|
542
|
+
* const fork = await sb.fork("after-install");
|
|
543
|
+
*
|
|
544
|
+
* await sb.exec("npm test"); // runs on original
|
|
545
|
+
* await fork.exec("npm run build"); // runs on fork
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
async fork(tag) {
|
|
549
|
+
if (!this._deps.fork) throw new SandboxError("fork() is not supported on this sandbox", this.sandboxId);
|
|
550
|
+
const snap = await this._deps.control.createSnapshot(this.sandboxId);
|
|
551
|
+
await this._waitForSnapshot(snap.id);
|
|
552
|
+
await this._emit("checkpoint_created", -1, {
|
|
553
|
+
snapshotId: snap.id,
|
|
554
|
+
name: tag
|
|
555
|
+
});
|
|
556
|
+
this._deps.hooks?.onCheckpoint?.(this.sandboxId, snap.id, tag);
|
|
557
|
+
return this._deps.fork(snap.id, tag);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Capture a snapshot of the sandbox's current filesystem state.
|
|
561
|
+
*
|
|
562
|
+
* Writes a `checkpoint_created` event to the ledger with the snapshot ID.
|
|
563
|
+
* Use `DrejClient.resume(sandboxId)` to restore from the latest checkpoint.
|
|
564
|
+
*/
|
|
565
|
+
async checkpoint(name) {
|
|
566
|
+
const snap = await this._deps.control.createSnapshot(this.sandboxId);
|
|
567
|
+
await this._waitForSnapshot(snap.id);
|
|
568
|
+
await this._emit("checkpoint_created", -1, {
|
|
569
|
+
snapshotId: snap.id,
|
|
570
|
+
name
|
|
571
|
+
});
|
|
572
|
+
this._deps.hooks?.onCheckpoint?.(this.sandboxId, snap.id, name);
|
|
573
|
+
}
|
|
574
|
+
async _waitForSnapshot(snapshotId, timeoutMs = 12e4) {
|
|
575
|
+
const deadline = Date.now() + timeoutMs;
|
|
576
|
+
while (Date.now() < deadline) {
|
|
577
|
+
const snap = await this._deps.control.getSnapshot(snapshotId);
|
|
578
|
+
if (snap.state === SnapshotState.Ready) return;
|
|
579
|
+
if (snap.state === SnapshotState.Failed) throw new SandboxError(`Snapshot ${snapshotId} failed`, this.sandboxId);
|
|
580
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
581
|
+
}
|
|
582
|
+
throw new SandboxError(`Snapshot ${snapshotId} did not become ready within ${timeoutMs}ms`, this.sandboxId);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Delete the sandbox container and release its resources.
|
|
586
|
+
*
|
|
587
|
+
* Always call `close()` when done — even on error — to avoid leaking containers.
|
|
588
|
+
* Idempotent: subsequent calls are no-ops.
|
|
589
|
+
*/
|
|
590
|
+
async close() {
|
|
591
|
+
if (this._closed) return;
|
|
592
|
+
this._closed = true;
|
|
593
|
+
try {
|
|
594
|
+
await this._deps.control.deleteSandbox(this.sandboxId);
|
|
595
|
+
} finally {
|
|
596
|
+
await this._emit("sandbox_closed", -1);
|
|
597
|
+
this._deps.hooks?.onSandboxClosed?.(this.sandboxId);
|
|
598
|
+
this._deps.onClose?.();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
//#endregion
|
|
603
|
+
export { CommandError, ConsoleLogger, ExecConnectionError, ExecHandle, LedgerEvent, LogLevel, Sandbox, SandboxError, SandboxStatus, StepTimeoutError, WorkflowError, noopLogger, resolveExecClient };
|
package/package.json
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drej/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"main": "./src/index.ts",
|
|
5
|
-
"types": "./dist/index.d.ts",
|
|
3
|
+
"version": "0.4.0",
|
|
6
4
|
"files": [
|
|
7
5
|
"dist"
|
|
8
6
|
],
|
|
9
|
-
"
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"types": "./dist/index.d.mts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
10
19
|
"scripts": {
|
|
11
|
-
"build
|
|
20
|
+
"build": "tsdown",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
12
23
|
},
|
|
13
24
|
"dependencies": {
|
|
14
25
|
"@drej/opensandbox": "workspace:*"
|
|
15
26
|
},
|
|
16
27
|
"devDependencies": {
|
|
17
|
-
"bun-types": "
|
|
18
|
-
"
|
|
28
|
+
"bun-types": "1.3.14",
|
|
29
|
+
"tsdown": "0.22.3",
|
|
30
|
+
"typescript": "6.0.3",
|
|
31
|
+
"vitest": "4.1.9"
|
|
19
32
|
}
|
|
20
33
|
}
|
package/dist/errors.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** Base class for all drej workflow runtime errors. */
|
|
2
|
-
export declare class WorkflowError extends Error {
|
|
3
|
-
constructor(message: string);
|
|
4
|
-
}
|
|
5
|
-
/** Thrown when a sandbox fails to create, boot, or reach `Running` state. */
|
|
6
|
-
export declare class SandboxError extends WorkflowError {
|
|
7
|
-
/** The sandbox ID, if one was assigned before the failure. */
|
|
8
|
-
readonly sandboxId?: string | undefined;
|
|
9
|
-
constructor(message: string,
|
|
10
|
-
/** The sandbox ID, if one was assigned before the failure. */
|
|
11
|
-
sandboxId?: string | undefined);
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Thrown when execd inside a sandbox never becomes ready within the retry window.
|
|
15
|
-
* The sandbox is `Running` from the control plane's perspective, but the exec
|
|
16
|
-
* daemon is not accepting connections.
|
|
17
|
-
*/
|
|
18
|
-
export declare class ExecConnectionError extends WorkflowError {
|
|
19
|
-
readonly sandboxId: string;
|
|
20
|
-
constructor(sandboxId: string);
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Thrown when an `exec()` step exits with a non-zero exit code and the
|
|
24
|
-
* `strict` option is enabled. Carries the exit code and original command.
|
|
25
|
-
*/
|
|
26
|
-
export declare class CommandError extends WorkflowError {
|
|
27
|
-
readonly exitCode: number;
|
|
28
|
-
readonly command: string;
|
|
29
|
-
readonly sandboxId: string;
|
|
30
|
-
constructor(exitCode: number, command: string, sandboxId: string);
|
|
31
|
-
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export { LedgerEvent } from "./ledger";
|
|
2
|
-
export type { LedgerEntry, IStorageAdapter } from "./ledger";
|
|
3
|
-
export { LogLevel, ConsoleLogger, noopLogger } from "./logger";
|
|
4
|
-
export type { ILogger } from "./logger";
|
|
5
|
-
export { Workflow, WorkflowStatus } from "./workflow";
|
|
6
|
-
export type { WorkflowRunContext, WorkflowStep, WorkflowCheckpoint, WorkflowDeps, WorkflowHooks, } from "./workflow";
|
|
7
|
-
export { buildStep, resolveExecClient, shouldSnapshot, waitForSnapshot } from "./steps";
|
|
8
|
-
export type { StepDef, Predicate, WorkflowState, SnapshotConfig } from "./steps";
|
|
9
|
-
export { validateWorkflow } from "./validate";
|
|
10
|
-
export { WorkflowError, SandboxError, ExecConnectionError, CommandError } from "./errors";
|