drej 0.2.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # drej
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d0486df: Add fluent workflow builder API to TypeScript SDK.
8
+
9
+ `workflow(id)` returns a `WorkflowBuilder` with chainable `.sandbox()` and `.parallel()` methods. Inside a sandbox scope, `SandboxStepBuilder` provides `.exec()`, `.writeFile()`, `.retry()`, `.forEach()`, `.when()`, and `.parallel()`. The `forEach` callback receives `(s, item)` where `item` serialises to `{{name}}` in template literals, enabling natural JS interpolation. Top-level `.parallel()` supports multiple concurrent sandbox sessions via `WorkflowParallelBuilder`. `DrejClient.run(w)` accepts a built workflow directly. The `sandbox()` helper defaults the entrypoint to `["tail", "-f", "/dev/null"]`.
10
+
11
+ Adds a server-side `sequence` step type that runs child steps sequentially, used internally by the builder to represent multi-step parallel branches.
12
+
13
+ - e1f9bb8: Add `snapshotConfig` option to `client.run()` and a `replayFromSnapshot()` method.
14
+
15
+ Pass `snapshotConfig: { afterSteps?: number[]; everyNSteps?: number }` to capture sandbox snapshots at specific points in a workflow. Call `client.replayFromSnapshot(name, runId, workflow)` to start a new run booted from the latest captured snapshot — skipping any setup steps already baked into the image.
16
+
17
+ - 9a30c31: Introduce per-run ledger with workflow name / run ID separation.
18
+
19
+ Each workflow execution now has a stable **workflow name** (user-defined) and an auto-generated **run ID** (UUID). Ledger files are stored at `ledgers/<name>/<runId>.ndjson` so all runs of a workflow are grouped together.
20
+
21
+ API changes:
22
+
23
+ - `POST /v1/workflows/:name/runs` — starts a run; first SSE event is `run_started` carrying the run ID
24
+ - `POST /v1/workflows/:name/runs/:runId/resume` — resumes a specific run
25
+ - `GET /v1/workflows/:name/runs` — lists all run IDs for a workflow
26
+ - `GET /v1/workflows/:name/runs/:runId/ledger` — fetches ledger for a specific run
27
+
28
+ SDK changes:
29
+
30
+ - `client.run(w)` is now `async` and returns `Promise<WorkflowRun>`; `run.id` gives the run ID, `run.name` the workflow name, and it is async-iterable for events
31
+ - `client.resumeRun(name, runId, w)` resumes a run
32
+ - `client.listWorkflowRuns(name)` lists runs
33
+ - `client.getWorkflowLedger(name, runId)` fetches the ledger
34
+ - `WorkflowEvent` fields renamed: `workflowId` → `workflowName` + `runId`
35
+
36
+ ### Patch Changes
37
+
38
+ - b3c0bc9: feat: lifecycle hooks, append-only WAL, and clean adapter layer
39
+
40
+ - Add `WorkflowHooks` interface with `onStepStart`, `onStepComplete`, `onStepFailed`, `onStepRolledBack`, `onWorkflowComplete`, `onWorkflowFailed` callbacks on `WorkflowDeps`
41
+ - Fix `NdjsonLedger.append` to use `appendFileSync` (O_APPEND) instead of read-then-overwrite (O_TRUNC), preventing ledger truncation on crash
42
+ - Make `NdjsonLedger.readAll` resilient to malformed lines from partial writes
43
+ - Add `OpenSandboxControlAdapter` and `OpenSandboxExecFactory` to `@drej/opensandbox` — concrete implementations of `ISandboxControl` and `IExecClientFactory` that encapsulate execd readiness polling
44
+ - Remove `as unknown as` double-cast from `apps/api`; adapter wiring is now explicit and type-safe
45
+
46
+ ## 0.3.0
47
+
48
+ ### Minor Changes
49
+
50
+ - 6256955: Add retry, conditional, loop, and parallel step types to the workflow engine.
51
+
52
+ - `retry` — retries a child step up to N times with fixed or exponential backoff
53
+ - `conditional` — branches on a structured predicate (`eq`, `neq`, `gt`, `lt`, `exists`, `and`, `or`) evaluated against workflow state
54
+ - `loop` — iterates over a static `items` array or a dot-path `over` pointing to an array in state; supports `concurrently` flag for parallel iterations
55
+ - `parallel` — fans out multiple steps with `Promise.all`; emits events with a `branch` index for demuxing
56
+ - `{{key}}` interpolation in `exec_command` so loop items and other state values can be referenced in command strings
57
+ - `branch` field added to `WorkflowEvent` to identify parallel branch origin
58
+ - `Predicate` type exported from the SDK for use with `conditional` steps
59
+
60
+ - 2da4112: Add workflow engine support: `runWorkflow()` now supports `create_sandbox`, `exec_code`, `exec_command`, and `delete_sandbox` step types with SSE streaming and saga rollback.
61
+ - eb72eea: Add `write_file` workflow step type and always base64-encode `exec_command` strings.
62
+
63
+ - `write_file` step writes text or binary content to a path inside the sandbox; accepts `encoding: "utf8"` (default) or `"base64"` for binary files
64
+ - `exec_command` now unconditionally base64-encodes the command string before sending to the container, eliminating all quoting and special-character edge cases
65
+
3
66
  ## 0.2.1
4
67
 
5
68
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drej",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "main": "./src/index.ts",
5
5
  "types": "./src/index.ts",
6
6
  "scripts": {
package/src/client.ts CHANGED
@@ -8,36 +8,677 @@ export class DrejError extends Error {
8
8
  }
9
9
  }
10
10
 
11
- export interface DrejClientOptions {
12
- baseUrl?: string;
11
+ // ── Types ──────────────────────────────────────────────────────────────────
12
+
13
+ export interface Resources {
14
+ cpu?: string;
15
+ memory?: string;
16
+ gpu?: string;
17
+ }
18
+
19
+ export interface ImageAuth {
20
+ username: string;
21
+ password: string;
22
+ }
23
+
24
+ export interface ImageSpec {
25
+ uri: string;
26
+ auth?: ImageAuth;
27
+ }
28
+
29
+ export type SandboxState =
30
+ | "Pending"
31
+ | "Running"
32
+ | "Pausing"
33
+ | "Paused"
34
+ | "Resuming"
35
+ | "Stopping"
36
+ | "Terminated"
37
+ | "Failed"
38
+ | "Unknown";
39
+
40
+ export interface SandboxStatus {
41
+ state: SandboxState;
42
+ reason?: string;
43
+ message?: string;
44
+ lastTransitionAt?: string;
13
45
  }
14
46
 
15
- export interface SandboxRunResult {
47
+ export interface Sandbox {
16
48
  id: string;
49
+ status: SandboxStatus;
50
+ createdAt: string;
51
+ expiresAt?: string | null;
52
+ image?: ImageSpec;
53
+ snapshotId?: string;
54
+ entrypoint?: string[];
55
+ metadata?: Record<string, string>;
56
+ }
57
+
58
+ export interface CreateSandboxOptions {
59
+ image?: ImageSpec;
60
+ snapshotId?: string;
61
+ timeout?: number;
62
+ resourceLimits?: Resources;
63
+ entrypoint?: string[];
64
+ env?: Record<string, string>;
65
+ metadata?: Record<string, string>;
66
+ secureAccess?: boolean;
67
+ }
68
+
69
+ export interface ListSandboxesOptions {
70
+ state?: SandboxState;
71
+ limit?: number;
72
+ offset?: number;
73
+ }
74
+
75
+ export type SnapshotState = "Pending" | "Committing" | "Pushing" | "Ready" | "Failed";
76
+
77
+ export interface Snapshot {
78
+ id: string;
79
+ sandboxId: string;
80
+ state: SnapshotState;
81
+ createdAt: string;
82
+ }
83
+
84
+ export interface ListSnapshotsOptions {
85
+ sandboxId?: string;
86
+ limit?: number;
87
+ offset?: number;
88
+ }
89
+
90
+ export type SSEEventType =
91
+ | "init"
92
+ | "status"
93
+ | "stdout"
94
+ | "stderr"
95
+ | "result"
96
+ | "execution_complete"
97
+ | "execution_count"
98
+ | "error"
99
+ | "ping"
100
+ | "message";
101
+
102
+ export interface SSEEvent {
103
+ type: SSEEventType;
104
+ text?: string;
105
+ results?: Record<string, string>;
106
+ error?: { name?: string; message: string };
107
+ execution_count?: number;
108
+ execution_time?: number;
109
+ timestamp: number;
110
+ }
111
+
112
+ export interface CodeContext {
113
+ id: string;
114
+ language: string;
115
+ }
116
+
117
+ export interface ExecuteCodeOptions {
17
118
  code: string;
18
- status: "queued";
119
+ context?: {
120
+ id: string;
121
+ language: string;
122
+ };
123
+ }
124
+
125
+ export interface ExecuteCommandOptions {
126
+ command: string;
127
+ cwd?: string;
128
+ background?: boolean;
129
+ timeout?: number;
130
+ uid?: number;
131
+ gid?: number;
132
+ envs?: Record<string, string>;
133
+ }
134
+
135
+ export interface CommandStatus {
136
+ session: string;
137
+ status: "running" | "completed" | "failed";
138
+ exitCode?: number;
139
+ }
140
+
141
+ export interface FileInfo {
142
+ path: string;
143
+ size: number;
144
+ mode: string;
145
+ modifiedAt: string;
146
+ isDirectory: boolean;
19
147
  }
20
148
 
149
+ export interface DirectoryEntry {
150
+ name: string;
151
+ path: string;
152
+ isDirectory: boolean;
153
+ size?: number;
154
+ }
155
+
156
+ export interface FileReplacement {
157
+ path: string;
158
+ old: string;
159
+ new: string;
160
+ }
161
+
162
+ export interface Metrics {
163
+ cpu: number;
164
+ memory: number;
165
+ timestamp: string;
166
+ }
167
+
168
+ export interface DiagnosticLog {
169
+ name: string;
170
+ size: number;
171
+ url?: string;
172
+ inline?: string;
173
+ }
174
+
175
+ export interface DiagnosticEvent {
176
+ timestamp: string;
177
+ type: string;
178
+ message: string;
179
+ }
180
+
181
+ export interface DrejClientOptions {
182
+ baseUrl?: string;
183
+ }
184
+
185
+ // ── Workflow types ─────────────────────────────────────────────────────────
186
+
187
+ export type WorkflowEventKind =
188
+ | "run_started"
189
+ | "step_start"
190
+ | "step_complete"
191
+ | "step_failed"
192
+ | "step_rolled_back"
193
+ | "workflow_complete"
194
+ | "workflow_failed"
195
+ | "checkpoint"
196
+ | "exec_event"
197
+ | "snapshot";
198
+
199
+ export interface SnapshotConfig {
200
+ /** Take a snapshot after these step indices (0-based). */
201
+ afterSteps?: number[];
202
+ /** Take a snapshot after every N steps. */
203
+ everyNSteps?: number;
204
+ }
205
+
206
+ export interface RunOptions {
207
+ snapshotConfig?: SnapshotConfig;
208
+ }
209
+
210
+ export interface WorkflowEvent {
211
+ ts: number;
212
+ workflowName: string;
213
+ runId: string;
214
+ stepIndex: number;
215
+ branch?: number;
216
+ event: WorkflowEventKind;
217
+ payload?: unknown;
218
+ error?: string;
219
+ result?: unknown;
220
+ }
221
+
222
+ export class WorkflowRun implements AsyncIterable<WorkflowEvent> {
223
+ constructor(
224
+ public readonly name: string,
225
+ public readonly id: string,
226
+ private readonly _events: AsyncGenerator<WorkflowEvent>,
227
+ ) {}
228
+
229
+ [Symbol.asyncIterator](): AsyncIterator<WorkflowEvent> {
230
+ return this._events;
231
+ }
232
+ }
233
+
234
+ export type Predicate =
235
+ | { op: "eq" | "neq"; field: string; value: unknown }
236
+ | { op: "gt" | "lt" | "gte" | "lte"; field: string; value: number }
237
+ | { op: "exists" | "not_exists"; field: string }
238
+ | { op: "and" | "or"; predicates: Predicate[] };
239
+
240
+ export type StepDef =
241
+ | {
242
+ type: "create_sandbox";
243
+ image?: ImageSpec;
244
+ snapshotId?: string;
245
+ timeout?: number;
246
+ entrypoint?: string[];
247
+ env?: Record<string, string>;
248
+ metadata?: Record<string, string>;
249
+ resourceLimits?: Resources;
250
+ }
251
+ | { type: "exec_code"; code: string; context?: { id: string; language: string } }
252
+ | { type: "exec_command"; command: string; cwd?: string; envs?: Record<string, string> }
253
+ | { type: "delete_sandbox" }
254
+ | { type: "write_file"; path: string; content: string; encoding?: "utf8" | "base64" }
255
+ | { type: "retry"; step: StepDef; maxAttempts: number; delayMs?: number; backoff?: "fixed" | "exponential" }
256
+ | { type: "conditional"; condition: Predicate; then: StepDef[]; else?: StepDef[] }
257
+ | { type: "loop"; over?: string; items?: unknown[]; as: string; steps: StepDef[]; concurrently?: boolean }
258
+ | { type: "parallel"; steps: StepDef[] }
259
+ | { type: "sequence"; steps: StepDef[] };
260
+
261
+ // ── SSE parsers ────────────────────────────────────────────────────────────
262
+
263
+ async function* parseWorkflowSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<WorkflowEvent> {
264
+ const reader = stream.getReader();
265
+ const decoder = new TextDecoder();
266
+ let buffer = "";
267
+
268
+ try {
269
+ while (true) {
270
+ const { done, value } = await reader.read();
271
+ if (done) break;
272
+ buffer += decoder.decode(value, { stream: true });
273
+ const blocks = buffer.split("\n\n");
274
+ buffer = blocks.pop() ?? "";
275
+ for (const block of blocks) {
276
+ if (!block.trim()) continue;
277
+ for (const line of block.split("\n")) {
278
+ if (line.startsWith("data:")) {
279
+ yield JSON.parse(line.slice(5).trim()) as WorkflowEvent;
280
+ }
281
+ }
282
+ }
283
+ }
284
+ } finally {
285
+ reader.releaseLock();
286
+ }
287
+ }
288
+
289
+ async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
290
+ const reader = stream.getReader();
291
+ const decoder = new TextDecoder();
292
+ let buffer = "";
293
+
294
+ try {
295
+ while (true) {
296
+ const { done, value } = await reader.read();
297
+ if (done) break;
298
+ buffer += decoder.decode(value, { stream: true });
299
+ const blocks = buffer.split("\n\n");
300
+ buffer = blocks.pop() ?? "";
301
+ for (const block of blocks) {
302
+ if (!block.trim()) continue;
303
+ let type: string | undefined;
304
+ let data: string | undefined;
305
+ for (const line of block.split("\n")) {
306
+ if (line.startsWith("event:")) type = line.slice(6).trim();
307
+ else if (line.startsWith("data:")) data = line.slice(5).trim();
308
+ }
309
+ if (data !== undefined) {
310
+ yield { type: (type ?? "message") as SSEEventType, ...JSON.parse(data) };
311
+ }
312
+ }
313
+ }
314
+ } finally {
315
+ reader.releaseLock();
316
+ }
317
+ }
318
+
319
+ // ── Client ─────────────────────────────────────────────────────────────────
320
+
21
321
  export class DrejClient {
22
322
  private baseUrl: string;
23
323
 
24
324
  constructor(options: DrejClientOptions = {}) {
25
- this.baseUrl = options.baseUrl ?? "http://localhost:3000";
325
+ this.baseUrl = (options.baseUrl ?? "http://localhost:6000").replace(/\/$/, "");
326
+ }
327
+
328
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
329
+ const res = await fetch(`${this.baseUrl}${path}`, {
330
+ method,
331
+ headers: body !== undefined ? { "Content-Type": "application/json" } : {},
332
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
333
+ });
334
+ if (!res.ok) throw new DrejError("drej API error", res.status);
335
+ if (res.status === 204) return undefined as T;
336
+ return res.json() as Promise<T>;
337
+ }
338
+
339
+ private async *streamRequest(
340
+ method: string,
341
+ path: string,
342
+ body?: unknown,
343
+ ): AsyncGenerator<SSEEvent> {
344
+ const res = await fetch(`${this.baseUrl}${path}`, {
345
+ method,
346
+ headers: body !== undefined ? { "Content-Type": "application/json" } : {},
347
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
348
+ });
349
+ if (!res.ok) throw new DrejError("drej API error", res.status);
350
+ if (!res.body) return;
351
+ yield* parseSSE(res.body);
352
+ }
353
+
354
+ // ── Health ───────────────────────────────────────────────────────────────
355
+
356
+ health(): Promise<{ healthy: boolean }> {
357
+ return this.request("GET", "/health");
358
+ }
359
+
360
+ // ── Sandbox lifecycle ────────────────────────────────────────────────────
361
+
362
+ createSandbox(options: CreateSandboxOptions): Promise<Sandbox> {
363
+ return this.request("POST", "/v1/sandboxes", options);
364
+ }
365
+
366
+ listSandboxes(options: ListSandboxesOptions = {}): Promise<Sandbox[]> {
367
+ const params = new URLSearchParams();
368
+ if (options.state) params.set("state", options.state);
369
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
370
+ if (options.offset !== undefined) params.set("offset", String(options.offset));
371
+ const qs = params.toString();
372
+ return this.request("GET", `/v1/sandboxes${qs ? `?${qs}` : ""}`);
373
+ }
374
+
375
+ getSandbox(id: string): Promise<Sandbox> {
376
+ return this.request("GET", `/v1/sandboxes/${id}`);
26
377
  }
27
378
 
28
- async health(): Promise<{ healthy: boolean }> {
29
- const res = await fetch(`${this.baseUrl}/health`);
30
- if (!res.ok) throw new DrejError(`drej API error`, res.status);
31
- return res.json();
379
+ deleteSandbox(id: string): Promise<void> {
380
+ return this.request("DELETE", `/v1/sandboxes/${id}`);
32
381
  }
33
382
 
34
- async run(code: string): Promise<SandboxRunResult> {
35
- const res = await fetch(`${this.baseUrl}/sandbox/run`, {
383
+ pauseSandbox(id: string): Promise<void> {
384
+ return this.request("POST", `/v1/sandboxes/${id}/pause`);
385
+ }
386
+
387
+ resumeSandbox(id: string): Promise<void> {
388
+ return this.request("POST", `/v1/sandboxes/${id}/resume`);
389
+ }
390
+
391
+ renewSandbox(id: string): Promise<void> {
392
+ return this.request("POST", `/v1/sandboxes/${id}/renew`);
393
+ }
394
+
395
+ async waitForRunning(
396
+ id: string,
397
+ options: { timeoutMs?: number; pollIntervalMs?: number } = {},
398
+ ): Promise<Sandbox> {
399
+ const { timeoutMs = 60_000, pollIntervalMs = 1_000 } = options;
400
+ const deadline = Date.now() + timeoutMs;
401
+ while (Date.now() < deadline) {
402
+ const sandbox = await this.getSandbox(id);
403
+ const { state } = sandbox.status;
404
+ if (state === "Running") return sandbox;
405
+ if (state === "Failed" || state === "Terminated") {
406
+ throw new DrejError(`Sandbox ${id} entered state ${state}`, 500);
407
+ }
408
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs));
409
+ }
410
+ throw new DrejError(`Sandbox ${id} did not reach Running within ${timeoutMs}ms`, 408);
411
+ }
412
+
413
+ // ── Snapshots ────────────────────────────────────────────────────────────
414
+
415
+ createSnapshot(sandboxId: string): Promise<Snapshot> {
416
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/snapshots`);
417
+ }
418
+
419
+ listSnapshots(options: ListSnapshotsOptions = {}): Promise<Snapshot[]> {
420
+ const params = new URLSearchParams();
421
+ if (options.sandboxId) params.set("sandboxId", options.sandboxId);
422
+ if (options.limit !== undefined) params.set("limit", String(options.limit));
423
+ if (options.offset !== undefined) params.set("offset", String(options.offset));
424
+ const qs = params.toString();
425
+ return this.request("GET", `/v1/snapshots${qs ? `?${qs}` : ""}`);
426
+ }
427
+
428
+ getSnapshot(id: string): Promise<Snapshot> {
429
+ return this.request("GET", `/v1/snapshots/${id}`);
430
+ }
431
+
432
+ deleteSnapshot(id: string): Promise<void> {
433
+ return this.request("DELETE", `/v1/snapshots/${id}`);
434
+ }
435
+
436
+ // ── Diagnostics ──────────────────────────────────────────────────────────
437
+
438
+ getDiagnosticLogs(sandboxId: string): Promise<DiagnosticLog[]> {
439
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/diagnostics/logs`);
440
+ }
441
+
442
+ getDiagnosticEvents(sandboxId: string): Promise<DiagnosticEvent[]> {
443
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/diagnostics/events`);
444
+ }
445
+
446
+ // ── Code execution ───────────────────────────────────────────────────────
447
+
448
+ async *executeCode(sandboxId: string, options: ExecuteCodeOptions): AsyncGenerator<SSEEvent> {
449
+ yield* this.streamRequest("POST", `/v1/sandboxes/${sandboxId}/exec/code`, options);
450
+ }
451
+
452
+ interruptCode(sandboxId: string): Promise<void> {
453
+ return this.request("DELETE", `/v1/sandboxes/${sandboxId}/exec/code`);
454
+ }
455
+
456
+ // ── Code contexts ────────────────────────────────────────────────────────
457
+
458
+ listContexts(sandboxId: string, language?: string): Promise<CodeContext[]> {
459
+ const qs = language ? `?language=${encodeURIComponent(language)}` : "";
460
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/exec/contexts${qs}`);
461
+ }
462
+
463
+ createContext(sandboxId: string, language: string): Promise<CodeContext> {
464
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/exec/contexts`, { language });
465
+ }
466
+
467
+ clearContexts(sandboxId: string, language?: string): Promise<void> {
468
+ const qs = language ? `?language=${encodeURIComponent(language)}` : "";
469
+ return this.request("DELETE", `/v1/sandboxes/${sandboxId}/exec/contexts${qs}`);
470
+ }
471
+
472
+ deleteContext(sandboxId: string, contextId: string): Promise<void> {
473
+ return this.request("DELETE", `/v1/sandboxes/${sandboxId}/exec/contexts/${contextId}`);
474
+ }
475
+
476
+ // ── Command execution ────────────────────────────────────────────────────
477
+
478
+ async *executeCommand(
479
+ sandboxId: string,
480
+ options: ExecuteCommandOptions,
481
+ ): AsyncGenerator<SSEEvent> {
482
+ yield* this.streamRequest("POST", `/v1/sandboxes/${sandboxId}/exec/command`, options);
483
+ }
484
+
485
+ interruptCommand(sandboxId: string): Promise<void> {
486
+ return this.request("DELETE", `/v1/sandboxes/${sandboxId}/exec/command`);
487
+ }
488
+
489
+ getCommandStatus(sandboxId: string, session: string): Promise<CommandStatus> {
490
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/exec/command/status/${session}`);
491
+ }
492
+
493
+ getCommandOutput(
494
+ sandboxId: string,
495
+ session: string,
496
+ ): Promise<{ stdout: string; stderr: string }> {
497
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/exec/command/output/${session}`);
498
+ }
499
+
500
+ // ── Files ────────────────────────────────────────────────────────────────
501
+
502
+ getFileInfo(sandboxId: string, path: string): Promise<FileInfo> {
503
+ return this.request(
504
+ "GET",
505
+ `/v1/sandboxes/${sandboxId}/files/info?path=${encodeURIComponent(path)}`,
506
+ );
507
+ }
508
+
509
+ deleteFile(sandboxId: string, path: string): Promise<void> {
510
+ return this.request(
511
+ "DELETE",
512
+ `/v1/sandboxes/${sandboxId}/files?path=${encodeURIComponent(path)}`,
513
+ );
514
+ }
515
+
516
+ setFilePermissions(sandboxId: string, path: string, mode: string): Promise<void> {
517
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/files/permissions`, { path, mode });
518
+ }
519
+
520
+ moveFile(sandboxId: string, from: string, to: string): Promise<void> {
521
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/files/move`, { from, to });
522
+ }
523
+
524
+ searchFiles(sandboxId: string, pattern: string, dir?: string): Promise<string[]> {
525
+ const params = new URLSearchParams({ pattern });
526
+ if (dir) params.set("dir", dir);
527
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/files/search?${params}`);
528
+ }
529
+
530
+ replaceInFiles(sandboxId: string, replacements: FileReplacement[]): Promise<void> {
531
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/files/replace`, { replacements });
532
+ }
533
+
534
+ async uploadFile(
535
+ sandboxId: string,
536
+ path: string,
537
+ content: Blob | BufferSource | string,
538
+ ): Promise<void> {
539
+ const blob = content instanceof Blob ? content : new Blob([content]);
540
+ const formData = new FormData();
541
+ formData.append("file", blob, path.split("/").pop());
542
+ formData.append("path", path);
543
+ const res = await fetch(`${this.baseUrl}/v1/sandboxes/${sandboxId}/files/upload`, {
36
544
  method: "POST",
37
- headers: { "Content-Type": "application/json" },
38
- body: JSON.stringify({ code }),
545
+ body: formData,
39
546
  });
40
- if (!res.ok) throw new DrejError(`drej API error`, res.status);
41
- return res.json();
547
+ if (!res.ok) throw new DrejError("drej API error", res.status);
548
+ }
549
+
550
+ async downloadFile(sandboxId: string, path: string): Promise<ReadableStream<Uint8Array>> {
551
+ const res = await fetch(
552
+ `${this.baseUrl}/v1/sandboxes/${sandboxId}/files/download?path=${encodeURIComponent(path)}`,
553
+ );
554
+ if (!res.ok) throw new DrejError("drej API error", res.status);
555
+ if (!res.body) throw new Error("empty response body");
556
+ return res.body;
557
+ }
558
+
559
+ // ── Directories ──────────────────────────────────────────────────────────
560
+
561
+ listDirectory(sandboxId: string, path: string, depth?: number): Promise<DirectoryEntry[]> {
562
+ const params = new URLSearchParams({ path });
563
+ if (depth !== undefined) params.set("depth", String(depth));
564
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/directories?${params}`);
565
+ }
566
+
567
+ createDirectory(sandboxId: string, path: string): Promise<void> {
568
+ return this.request("POST", `/v1/sandboxes/${sandboxId}/directories`, { path });
569
+ }
570
+
571
+ deleteDirectory(sandboxId: string, path: string): Promise<void> {
572
+ return this.request(
573
+ "DELETE",
574
+ `/v1/sandboxes/${sandboxId}/directories?path=${encodeURIComponent(path)}`,
575
+ );
576
+ }
577
+
578
+ // ── Metrics ──────────────────────────────────────────────────────────────
579
+
580
+ getMetrics(sandboxId: string): Promise<Metrics> {
581
+ return this.request("GET", `/v1/sandboxes/${sandboxId}/metrics`);
582
+ }
583
+
584
+ async *watchMetrics(sandboxId: string): AsyncGenerator<SSEEvent> {
585
+ yield* this.streamRequest("GET", `/v1/sandboxes/${sandboxId}/metrics/watch`);
586
+ }
587
+
588
+ // ── Workflows ────────────────────────────────────────────────────────────
589
+
590
+ async run(
591
+ w: { build(): { name: string; steps: StepDef[] } },
592
+ options?: RunOptions,
593
+ ): Promise<WorkflowRun> {
594
+ const { name, steps } = w.build();
595
+ return this._startRun(name, steps, options?.snapshotConfig);
596
+ }
597
+
598
+ /**
599
+ * Start a new run using a snapshot captured from a previous run.
600
+ *
601
+ * Reads the ledger for the given run, finds the latest snapshot entry,
602
+ * and injects the snapshotId into the first `create_sandbox` step of the
603
+ * provided workflow — so the new sandbox boots from that snapshot with the
604
+ * previous environment already in place.
605
+ */
606
+ async replayFromSnapshot(
607
+ name: string,
608
+ runId: string,
609
+ w: { build(): { name: string; steps: StepDef[] } },
610
+ ): Promise<WorkflowRun> {
611
+ const ledger = await this.getWorkflowLedger(name, runId);
612
+ const snapEntry = [...ledger].reverse().find((e) => e.event === "snapshot");
613
+ if (!snapEntry) throw new DrejError(`No snapshot found in ledger for ${name}/${runId}`, 404);
614
+ const { snapshotId } = snapEntry.payload as { snapshotId: string };
615
+
616
+ const { name: wfName, steps } = w.build();
617
+ const replaySteps: StepDef[] = steps.map((step) =>
618
+ step.type === "create_sandbox" ? { ...step, snapshotId } : step,
619
+ );
620
+
621
+ return this._startRun(wfName, replaySteps);
622
+ }
623
+
624
+ async resumeRun(
625
+ name: string,
626
+ runId: string,
627
+ w: { build(): { name: string; steps: StepDef[] } } | StepDef[],
628
+ ): Promise<WorkflowRun> {
629
+ const steps = Array.isArray(w) ? w : w.build().steps;
630
+ const res = await fetch(
631
+ `${this.baseUrl}/v1/workflows/${encodeURIComponent(name)}/runs/${encodeURIComponent(runId)}/resume`,
632
+ {
633
+ method: "POST",
634
+ headers: { "Content-Type": "application/json" },
635
+ body: JSON.stringify({ steps }),
636
+ },
637
+ );
638
+ if (!res.ok) throw new DrejError("drej API error", res.status);
639
+ if (!res.body) throw new DrejError("empty response body", 500);
640
+ return this._consumeRunStarted(name, res.body);
641
+ }
642
+
643
+ listWorkflowRuns(name: string): Promise<{ runs: string[] }> {
644
+ return this.request("GET", `/v1/workflows/${encodeURIComponent(name)}/runs`);
645
+ }
646
+
647
+ getWorkflowLedger(name: string, runId: string): Promise<WorkflowEvent[]> {
648
+ return this.request(
649
+ "GET",
650
+ `/v1/workflows/${encodeURIComponent(name)}/runs/${encodeURIComponent(runId)}/ledger`,
651
+ );
652
+ }
653
+
654
+ private async _startRun(
655
+ name: string,
656
+ steps: StepDef[],
657
+ snapshotConfig?: SnapshotConfig,
658
+ ): Promise<WorkflowRun> {
659
+ const res = await fetch(
660
+ `${this.baseUrl}/v1/workflows/${encodeURIComponent(name)}/runs`,
661
+ {
662
+ method: "POST",
663
+ headers: { "Content-Type": "application/json" },
664
+ body: JSON.stringify({ steps, ...(snapshotConfig ? { snapshotConfig } : {}) }),
665
+ },
666
+ );
667
+ if (!res.ok) throw new DrejError("drej API error", res.status);
668
+ if (!res.body) throw new DrejError("empty response body", 500);
669
+ return this._consumeRunStarted(name, res.body);
670
+ }
671
+
672
+ private async _consumeRunStarted(
673
+ name: string,
674
+ body: ReadableStream<Uint8Array>,
675
+ ): Promise<WorkflowRun> {
676
+ const stream = parseWorkflowSSE(body);
677
+ const first = await stream.next();
678
+ if (first.done || first.value.event !== "run_started") {
679
+ throw new DrejError("expected run_started as first SSE event", 500);
680
+ }
681
+ const { runId } = first.value.payload as { runId: string };
682
+ return new WorkflowRun(name, runId, stream);
42
683
  }
43
684
  }
package/src/index.ts CHANGED
@@ -1,2 +1,35 @@
1
- export { DrejClient, DrejError } from "./client";
2
- export type { DrejClientOptions, SandboxRunResult } from "./client";
1
+ export { DrejClient, DrejError, WorkflowRun } from "./client";
2
+ export type {
3
+ DrejClientOptions,
4
+ Resources,
5
+ ImageAuth,
6
+ ImageSpec,
7
+ Sandbox,
8
+ SandboxState,
9
+ SandboxStatus,
10
+ CreateSandboxOptions,
11
+ ListSandboxesOptions,
12
+ Snapshot,
13
+ SnapshotState,
14
+ ListSnapshotsOptions,
15
+ SSEEvent,
16
+ SSEEventType,
17
+ CodeContext,
18
+ ExecuteCodeOptions,
19
+ ExecuteCommandOptions,
20
+ CommandStatus,
21
+ FileInfo,
22
+ DirectoryEntry,
23
+ FileReplacement,
24
+ Metrics,
25
+ DiagnosticLog,
26
+ DiagnosticEvent,
27
+ WorkflowEvent,
28
+ WorkflowEventKind,
29
+ StepDef,
30
+ SnapshotConfig,
31
+ RunOptions,
32
+ } from "./client";
33
+
34
+ export { workflow, WorkflowBuilder, SandboxStepBuilder } from "./workflow";
35
+ export type { SandboxOpts, LoopItem } from "./workflow";
@@ -0,0 +1,214 @@
1
+ import type { StepDef, ImageSpec, Resources, Predicate } from "./client";
2
+
3
+ // Placeholder that serialises to {{name}} inside template literals.
4
+ // Used as the `item` parameter in forEach callbacks so users write
5
+ // `s.exec(`upload ${item}`)` instead of `s.exec("upload {{item}}")`.
6
+ class LoopVar {
7
+ constructor(private name: string) {}
8
+ toString() {
9
+ return `{{${this.name}}}`;
10
+ }
11
+ }
12
+
13
+ export type LoopItem = { toString(): string };
14
+
15
+ export type SandboxOpts = {
16
+ image?: ImageSpec;
17
+ snapshotId?: string;
18
+ timeout?: number;
19
+ entrypoint?: string[];
20
+ env?: Record<string, string>;
21
+ metadata?: Record<string, string>;
22
+ resourceLimits?: Resources;
23
+ };
24
+
25
+ type ForEachOpts = {
26
+ concurrency?: number;
27
+ as?: string;
28
+ };
29
+
30
+ type ForEachSource = unknown[] | { from: string };
31
+ type ForEachCallback = (s: SandboxStepBuilder, item: LoopItem) => SandboxStepBuilder | string;
32
+
33
+ function wrapSteps(steps: StepDef[]): StepDef {
34
+ return steps.length === 1 ? steps[0] : { type: "sequence", steps };
35
+ }
36
+
37
+ // ── SandboxStepBuilder ────────────────────────────────────────────────────────
38
+
39
+ export class SandboxStepBuilder {
40
+ protected _steps: StepDef[] = [];
41
+
42
+ exec(command: string, opts?: { cwd?: string; envs?: Record<string, string> }): this {
43
+ this._steps.push({ type: "exec_command", command, ...opts });
44
+ return this;
45
+ }
46
+
47
+ writeFile(path: string, content: string, encoding?: "utf8" | "base64"): this {
48
+ this._steps.push({ type: "write_file", path, content, ...(encoding ? { encoding } : {}) });
49
+ return this;
50
+ }
51
+
52
+ retry(
53
+ maxAttempts: number,
54
+ fn: (s: SandboxStepBuilder) => SandboxStepBuilder,
55
+ opts?: { delayMs?: number; backoff?: "fixed" | "exponential" },
56
+ ): this {
57
+ const inner = new SandboxStepBuilder();
58
+ fn(inner);
59
+ this._steps.push({ type: "retry", step: wrapSteps(inner.build()), maxAttempts, ...opts });
60
+ return this;
61
+ }
62
+
63
+ forEach(source: ForEachSource, fn: ForEachCallback): this;
64
+ forEach(source: ForEachSource, opts: ForEachOpts, fn: ForEachCallback): this;
65
+ forEach(
66
+ source: ForEachSource,
67
+ optsOrFn: ForEachOpts | ForEachCallback,
68
+ fn?: ForEachCallback,
69
+ ): this {
70
+ const opts: ForEachOpts = typeof optsOrFn === "function" ? {} : optsOrFn;
71
+ const callback: ForEachCallback = typeof optsOrFn === "function" ? optsOrFn : fn!;
72
+
73
+ const varName = opts.as ?? "item";
74
+ const loopVar = new LoopVar(varName);
75
+ const inner = new SandboxStepBuilder();
76
+ const result = callback(inner, loopVar);
77
+
78
+ const steps: StepDef[] =
79
+ typeof result === "string"
80
+ ? [{ type: "exec_command", command: result }]
81
+ : result.build();
82
+
83
+ this._steps.push({
84
+ type: "loop",
85
+ as: varName,
86
+ steps,
87
+ ...(Array.isArray(source) ? { items: source } : { over: source.from }),
88
+ ...(opts.concurrency !== undefined && opts.concurrency > 1 ? { concurrently: true } : {}),
89
+ });
90
+
91
+ return this;
92
+ }
93
+
94
+ when(
95
+ condition: Predicate,
96
+ thenFn: (s: SandboxStepBuilder) => SandboxStepBuilder,
97
+ elseFn?: (s: SandboxStepBuilder) => SandboxStepBuilder,
98
+ ): this {
99
+ const thenBuilder = new SandboxStepBuilder();
100
+ thenFn(thenBuilder);
101
+
102
+ const elseSteps = elseFn
103
+ ? (() => {
104
+ const b = new SandboxStepBuilder();
105
+ elseFn(b);
106
+ return b.build();
107
+ })()
108
+ : undefined;
109
+
110
+ this._steps.push({
111
+ type: "conditional",
112
+ condition,
113
+ then: thenBuilder.build(),
114
+ ...(elseSteps ? { else: elseSteps } : {}),
115
+ });
116
+
117
+ return this;
118
+ }
119
+
120
+ parallel(fn: (p: SandboxParallelBuilder) => SandboxParallelBuilder): this {
121
+ const pb = new SandboxParallelBuilder();
122
+ fn(pb);
123
+ this._steps.push({ type: "parallel", steps: pb.build() });
124
+ return this;
125
+ }
126
+
127
+ build(): StepDef[] {
128
+ return [...this._steps];
129
+ }
130
+ }
131
+
132
+ // ── SandboxParallelBuilder ────────────────────────────────────────────────────
133
+ // Used inside a sandbox scope — branches share the same sandbox, no new ones.
134
+
135
+ class SandboxParallelBuilder {
136
+ private _branches: StepDef[] = [];
137
+
138
+ branch(fn: (s: SandboxStepBuilder) => SandboxStepBuilder): this {
139
+ const sb = new SandboxStepBuilder();
140
+ fn(sb);
141
+ this._branches.push(wrapSteps(sb.build()));
142
+ return this;
143
+ }
144
+
145
+ build(): StepDef[] {
146
+ return this._branches;
147
+ }
148
+ }
149
+
150
+ // ── WorkflowParallelBuilder ───────────────────────────────────────────────────
151
+ // Used at the top-level workflow scope — each branch can own its own sandbox.
152
+
153
+ class WorkflowParallelBuilder {
154
+ private _branches: StepDef[] = [];
155
+
156
+ sandbox(opts: SandboxOpts, fn: (s: SandboxStepBuilder) => SandboxStepBuilder): this {
157
+ const sb = new SandboxStepBuilder();
158
+ fn(sb);
159
+ this._branches.push({
160
+ type: "sequence",
161
+ steps: [
162
+ { type: "create_sandbox", entrypoint: ["tail", "-f", "/dev/null"], ...opts },
163
+ ...sb.build(),
164
+ { type: "delete_sandbox" },
165
+ ],
166
+ });
167
+ return this;
168
+ }
169
+
170
+ branch(fn: (s: SandboxStepBuilder) => SandboxStepBuilder): this {
171
+ const sb = new SandboxStepBuilder();
172
+ fn(sb);
173
+ this._branches.push(wrapSteps(sb.build()));
174
+ return this;
175
+ }
176
+
177
+ build(): StepDef[] {
178
+ return this._branches;
179
+ }
180
+ }
181
+
182
+ // ── WorkflowBuilder ───────────────────────────────────────────────────────────
183
+
184
+ export class WorkflowBuilder {
185
+ private _steps: StepDef[] = [];
186
+
187
+ constructor(private _name: string) {}
188
+
189
+ sandbox(opts: SandboxOpts, fn: (s: SandboxStepBuilder) => SandboxStepBuilder): this {
190
+ const sb = new SandboxStepBuilder();
191
+ fn(sb);
192
+ this._steps.push(
193
+ { type: "create_sandbox", entrypoint: ["tail", "-f", "/dev/null"], ...opts },
194
+ ...sb.build(),
195
+ { type: "delete_sandbox" },
196
+ );
197
+ return this;
198
+ }
199
+
200
+ parallel(fn: (p: WorkflowParallelBuilder) => WorkflowParallelBuilder): this {
201
+ const pb = new WorkflowParallelBuilder();
202
+ fn(pb);
203
+ this._steps.push({ type: "parallel", steps: pb.build() });
204
+ return this;
205
+ }
206
+
207
+ build(): { name: string; steps: StepDef[] } {
208
+ return { name: this._name, steps: this._steps };
209
+ }
210
+ }
211
+
212
+ export function workflow(name: string): WorkflowBuilder {
213
+ return new WorkflowBuilder(name);
214
+ }