@drej/workflow 1.1.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 +229 -0
- package/dist/index.mjs +423 -0
- package/package.json +33 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Drej, ExecCodeOptions, ExecOptions, SandboxOptions } from "drej";
|
|
2
|
+
|
|
3
|
+
//#region src/sandbox-builder.d.ts
|
|
4
|
+
/** An operation queued on a SandboxBuilder and executed later. */
|
|
5
|
+
type SandboxOp = {
|
|
6
|
+
kind: "exec";
|
|
7
|
+
cmd: string;
|
|
8
|
+
opts: ExecOptions;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "execCode";
|
|
11
|
+
code: string;
|
|
12
|
+
opts: ExecCodeOptions;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "writeFile";
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "readFile";
|
|
19
|
+
path: string;
|
|
20
|
+
as: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "deleteFile";
|
|
23
|
+
path: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "moveFile";
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "checkpoint";
|
|
30
|
+
name?: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "retry";
|
|
33
|
+
maxAttempts: number;
|
|
34
|
+
fn: (sb: SandboxBuilder) => void;
|
|
35
|
+
opts: RetryOptions;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "when";
|
|
38
|
+
pred: WhenPredicate;
|
|
39
|
+
then: (sb: SandboxBuilder) => void;
|
|
40
|
+
else?: (sb: SandboxBuilder) => void;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "forEach";
|
|
43
|
+
items: unknown[];
|
|
44
|
+
fn: (sb: SandboxBuilder, item: unknown, index: number) => void;
|
|
45
|
+
opts: ForEachOptions;
|
|
46
|
+
};
|
|
47
|
+
interface RetryOptions {
|
|
48
|
+
backoff?: "fixed" | "exponential";
|
|
49
|
+
delayMs?: number;
|
|
50
|
+
}
|
|
51
|
+
type WhenPredicate = (ctx: {
|
|
52
|
+
stdout: string;
|
|
53
|
+
exitCode: number;
|
|
54
|
+
vars: Record<string, unknown>;
|
|
55
|
+
}) => boolean;
|
|
56
|
+
interface ForEachOptions {
|
|
57
|
+
concurrency?: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Synchronous lazy builder for sandbox operations.
|
|
61
|
+
*
|
|
62
|
+
* All methods queue an operation and return `this` — no awaits, no async.
|
|
63
|
+
* The queue is flushed when `WorkflowBuilder.pipe()` or `.result()` is called.
|
|
64
|
+
* This gives a single `await` at the end regardless of workflow complexity.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* await workflow(client)
|
|
69
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
70
|
+
* sb.exec("npm ci")
|
|
71
|
+
* sb.checkpoint()
|
|
72
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
73
|
+
* })
|
|
74
|
+
* .pipe(process.stdout);
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
declare class SandboxBuilder {
|
|
78
|
+
readonly _ops: SandboxOp[];
|
|
79
|
+
/** Queue a shell command. */
|
|
80
|
+
exec(cmd: string, opts?: ExecOptions): this;
|
|
81
|
+
/** Queue a code execution (Python/JS/TS via execd code interpreter). */
|
|
82
|
+
execCode(code: string, opts?: ExecCodeOptions): this;
|
|
83
|
+
/** Queue a file write into the sandbox. */
|
|
84
|
+
writeFile(path: string, content: string): this;
|
|
85
|
+
/** Queue a file read from the sandbox, stored in `vars[as]`. */
|
|
86
|
+
readFile(path: string, as: string): this;
|
|
87
|
+
/** Queue a file deletion inside the sandbox. */
|
|
88
|
+
deleteFile(path: string): this;
|
|
89
|
+
/** Queue a file move/rename inside the sandbox. */
|
|
90
|
+
moveFile(from: string, to: string): this;
|
|
91
|
+
/** Queue a checkpoint (snapshot). */
|
|
92
|
+
checkpoint(name?: string): this;
|
|
93
|
+
/**
|
|
94
|
+
* Retry an inner builder callback up to `maxAttempts` times on failure.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
retry(maxAttempts: number, fn: (sb: SandboxBuilder) => void, opts?: RetryOptions): this;
|
|
102
|
+
/**
|
|
103
|
+
* Conditionally execute one of two builder callbacks based on a predicate.
|
|
104
|
+
*
|
|
105
|
+
* The predicate receives `{ stdout, exitCode, vars }` from the last exec.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* sb.when(
|
|
110
|
+
* (ctx) => ctx.exitCode === 0,
|
|
111
|
+
* (sb) => sb.exec("echo success"),
|
|
112
|
+
* (sb) => sb.exec("echo failure"),
|
|
113
|
+
* )
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
when(pred: WhenPredicate, then: (sb: SandboxBuilder) => void, otherwise?: (sb: SandboxBuilder) => void): this;
|
|
117
|
+
/**
|
|
118
|
+
* Execute a builder callback for each item in `items`, optionally in parallel.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* sb.forEach(["a", "b", "c"], (sb, item) => sb.exec(`echo ${item}`))
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
forEach(items: unknown[], fn: (sb: SandboxBuilder, item: unknown, index: number) => void, opts?: ForEachOptions): this;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/workflow-builder.d.ts
|
|
129
|
+
interface WorkflowResult {
|
|
130
|
+
/** Concatenated stdout from all sandboxes in the workflow. */
|
|
131
|
+
stdout: string;
|
|
132
|
+
/** Named values captured by `sb.readFile(path, as)`. */
|
|
133
|
+
vars: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
/** A single step in a `.sequence()` call. */
|
|
136
|
+
interface SequenceStep {
|
|
137
|
+
image: SandboxOptions["image"];
|
|
138
|
+
resources: SandboxOptions["resources"];
|
|
139
|
+
env?: SandboxOptions["env"];
|
|
140
|
+
timeout?: SandboxOptions["timeout"];
|
|
141
|
+
name?: string;
|
|
142
|
+
run: (sb: SandboxBuilder, prevResult?: WorkflowResult) => void;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Lazy workflow builder. Collects stages and executes them all when `.pipe()` or
|
|
146
|
+
* `.result()` is awaited. Sandboxes are created via the `Drej` client internally
|
|
147
|
+
* — the user never manages sandbox lifecycle when using this layer.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { workflow } from "@drej/workflow";
|
|
152
|
+
*
|
|
153
|
+
* await workflow(client)
|
|
154
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
155
|
+
* sb.exec("npm ci")
|
|
156
|
+
* sb.checkpoint()
|
|
157
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
158
|
+
* })
|
|
159
|
+
* .pipe(process.stdout);
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
declare class WorkflowBuilder {
|
|
163
|
+
private readonly _client;
|
|
164
|
+
private readonly _stages;
|
|
165
|
+
constructor(client: Drej);
|
|
166
|
+
/**
|
|
167
|
+
* Add a sandbox stage. The `fn` callback receives a `SandboxBuilder` to
|
|
168
|
+
* queue operations synchronously. Sandboxes across multiple `.sandbox()` calls
|
|
169
|
+
* run sequentially, top-to-bottom.
|
|
170
|
+
*/
|
|
171
|
+
sandbox(opts: SandboxOptions, fn: (sb: SandboxBuilder) => void): this;
|
|
172
|
+
/**
|
|
173
|
+
* Run the same operation across multiple sandbox configurations in parallel.
|
|
174
|
+
* All sandboxes receive the same `fn` callback.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* await workflow(client)
|
|
179
|
+
* .parallel(
|
|
180
|
+
* [{ image: "node:20" }, { image: "node:22" }, { image: "node:24" }],
|
|
181
|
+
* (sb) => sb.exec("npm test"),
|
|
182
|
+
* )
|
|
183
|
+
* .pipe(process.stdout);
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
parallel(configs: SandboxOptions[], fn: (sb: SandboxBuilder) => void): this;
|
|
187
|
+
/**
|
|
188
|
+
* Run sandboxes in sequence, passing the previous stage's result to the next.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* await workflow(client)
|
|
193
|
+
* .sequence([
|
|
194
|
+
* { image: "node:22", run: (sb) => sb.exec("npm run build") },
|
|
195
|
+
* { image: "ubuntu:22.04", run: (sb, prev) => sb.exec("./deploy.sh") },
|
|
196
|
+
* ])
|
|
197
|
+
* .pipe(process.stdout);
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
sequence(steps: SequenceStep[]): this;
|
|
201
|
+
/** Execute the workflow and pipe stdout to a writable. */
|
|
202
|
+
pipe(writable: {
|
|
203
|
+
write(chunk: string): unknown;
|
|
204
|
+
}): Promise<void>;
|
|
205
|
+
/** Execute the workflow and return the full result. */
|
|
206
|
+
result(): Promise<WorkflowResult>;
|
|
207
|
+
private _execute;
|
|
208
|
+
private _runSandbox;
|
|
209
|
+
private _runParallel;
|
|
210
|
+
private _runSequence;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Create a workflow builder attached to a `Drej` client.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* import { workflow } from "@drej/workflow";
|
|
218
|
+
*
|
|
219
|
+
* await workflow(client)
|
|
220
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
221
|
+
* sb.exec("npm ci")
|
|
222
|
+
* sb.exec("npm test")
|
|
223
|
+
* })
|
|
224
|
+
* .pipe(process.stdout);
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
declare function workflow(client: Drej): WorkflowBuilder;
|
|
228
|
+
//#endregion
|
|
229
|
+
export { type ForEachOptions, type RetryOptions, SandboxBuilder, type SandboxOp, type SequenceStep, type WhenPredicate, WorkflowBuilder, type WorkflowResult, workflow };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
//#region src/sandbox-builder.ts
|
|
2
|
+
/**
|
|
3
|
+
* Synchronous lazy builder for sandbox operations.
|
|
4
|
+
*
|
|
5
|
+
* All methods queue an operation and return `this` — no awaits, no async.
|
|
6
|
+
* The queue is flushed when `WorkflowBuilder.pipe()` or `.result()` is called.
|
|
7
|
+
* This gives a single `await` at the end regardless of workflow complexity.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* await workflow(client)
|
|
12
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
13
|
+
* sb.exec("npm ci")
|
|
14
|
+
* sb.checkpoint()
|
|
15
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
16
|
+
* })
|
|
17
|
+
* .pipe(process.stdout);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
var SandboxBuilder = class {
|
|
21
|
+
_ops = [];
|
|
22
|
+
/** Queue a shell command. */
|
|
23
|
+
exec(cmd, opts = {}) {
|
|
24
|
+
this._ops.push({
|
|
25
|
+
kind: "exec",
|
|
26
|
+
cmd,
|
|
27
|
+
opts
|
|
28
|
+
});
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
/** Queue a code execution (Python/JS/TS via execd code interpreter). */
|
|
32
|
+
execCode(code, opts = {}) {
|
|
33
|
+
this._ops.push({
|
|
34
|
+
kind: "execCode",
|
|
35
|
+
code,
|
|
36
|
+
opts
|
|
37
|
+
});
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
/** Queue a file write into the sandbox. */
|
|
41
|
+
writeFile(path, content) {
|
|
42
|
+
this._ops.push({
|
|
43
|
+
kind: "writeFile",
|
|
44
|
+
path,
|
|
45
|
+
content
|
|
46
|
+
});
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
/** Queue a file read from the sandbox, stored in `vars[as]`. */
|
|
50
|
+
readFile(path, as) {
|
|
51
|
+
this._ops.push({
|
|
52
|
+
kind: "readFile",
|
|
53
|
+
path,
|
|
54
|
+
as
|
|
55
|
+
});
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
/** Queue a file deletion inside the sandbox. */
|
|
59
|
+
deleteFile(path) {
|
|
60
|
+
this._ops.push({
|
|
61
|
+
kind: "deleteFile",
|
|
62
|
+
path
|
|
63
|
+
});
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
/** Queue a file move/rename inside the sandbox. */
|
|
67
|
+
moveFile(from, to) {
|
|
68
|
+
this._ops.push({
|
|
69
|
+
kind: "moveFile",
|
|
70
|
+
from,
|
|
71
|
+
to
|
|
72
|
+
});
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
/** Queue a checkpoint (snapshot). */
|
|
76
|
+
checkpoint(name) {
|
|
77
|
+
this._ops.push({
|
|
78
|
+
kind: "checkpoint",
|
|
79
|
+
name
|
|
80
|
+
});
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Retry an inner builder callback up to `maxAttempts` times on failure.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
retry(maxAttempts, fn, opts = {}) {
|
|
92
|
+
this._ops.push({
|
|
93
|
+
kind: "retry",
|
|
94
|
+
maxAttempts,
|
|
95
|
+
fn,
|
|
96
|
+
opts
|
|
97
|
+
});
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Conditionally execute one of two builder callbacks based on a predicate.
|
|
102
|
+
*
|
|
103
|
+
* The predicate receives `{ stdout, exitCode, vars }` from the last exec.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* sb.when(
|
|
108
|
+
* (ctx) => ctx.exitCode === 0,
|
|
109
|
+
* (sb) => sb.exec("echo success"),
|
|
110
|
+
* (sb) => sb.exec("echo failure"),
|
|
111
|
+
* )
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
when(pred, then, otherwise) {
|
|
115
|
+
this._ops.push({
|
|
116
|
+
kind: "when",
|
|
117
|
+
pred,
|
|
118
|
+
then,
|
|
119
|
+
else: otherwise
|
|
120
|
+
});
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Execute a builder callback for each item in `items`, optionally in parallel.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* sb.forEach(["a", "b", "c"], (sb, item) => sb.exec(`echo ${item}`))
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
forEach(items, fn, opts = {}) {
|
|
132
|
+
this._ops.push({
|
|
133
|
+
kind: "forEach",
|
|
134
|
+
items,
|
|
135
|
+
fn,
|
|
136
|
+
opts
|
|
137
|
+
});
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/** Flush a SandboxBuilder's op queue against a live Sandbox. */
|
|
142
|
+
async function flushOps(sandbox, ops, ctx) {
|
|
143
|
+
for (const op of ops) switch (op.kind) {
|
|
144
|
+
case "exec": {
|
|
145
|
+
const handle = sandbox.exec(op.cmd, op.opts);
|
|
146
|
+
if (ctx.sink) {
|
|
147
|
+
for await (const chunk of handle.stdout()) {
|
|
148
|
+
ctx.stdout += chunk;
|
|
149
|
+
ctx.sink.write(chunk);
|
|
150
|
+
}
|
|
151
|
+
ctx.exitCode = (await handle.result()).exitCode;
|
|
152
|
+
} else {
|
|
153
|
+
const result = await handle;
|
|
154
|
+
ctx.stdout += result.stdout;
|
|
155
|
+
ctx.exitCode = result.exitCode;
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "execCode": {
|
|
160
|
+
const handle = sandbox.execCode(op.code, op.opts);
|
|
161
|
+
if (ctx.sink) {
|
|
162
|
+
for await (const chunk of handle.stdout()) {
|
|
163
|
+
ctx.stdout += chunk;
|
|
164
|
+
ctx.sink.write(chunk);
|
|
165
|
+
}
|
|
166
|
+
ctx.exitCode = (await handle.result()).exitCode;
|
|
167
|
+
} else {
|
|
168
|
+
const result = await handle;
|
|
169
|
+
ctx.stdout += result.stdout;
|
|
170
|
+
ctx.exitCode = result.exitCode;
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "writeFile":
|
|
175
|
+
await sandbox.writeFile(op.path, op.content);
|
|
176
|
+
break;
|
|
177
|
+
case "readFile": {
|
|
178
|
+
const content = await sandbox.readFile(op.path);
|
|
179
|
+
ctx.vars[op.as] = content;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "deleteFile":
|
|
183
|
+
await sandbox.deleteFile(op.path);
|
|
184
|
+
break;
|
|
185
|
+
case "moveFile":
|
|
186
|
+
await sandbox.moveFile(op.from, op.to);
|
|
187
|
+
break;
|
|
188
|
+
case "checkpoint":
|
|
189
|
+
await sandbox.checkpoint(op.name);
|
|
190
|
+
break;
|
|
191
|
+
case "retry":
|
|
192
|
+
await flushRetry(sandbox, op.fn, op.maxAttempts, op.opts, ctx);
|
|
193
|
+
break;
|
|
194
|
+
case "when": {
|
|
195
|
+
const branch = op.pred(ctx) ? op.then : op["else"];
|
|
196
|
+
if (branch) {
|
|
197
|
+
const inner = new SandboxBuilder();
|
|
198
|
+
branch(inner);
|
|
199
|
+
await flushOps(sandbox, inner._ops, ctx);
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "forEach":
|
|
204
|
+
await flushForEach(sandbox, op.items, op.fn, op.opts, ctx);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function flushRetry(sandbox, fn, maxAttempts, opts, ctx) {
|
|
209
|
+
let lastErr;
|
|
210
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
211
|
+
if (attempt > 0) {
|
|
212
|
+
const base = opts.delayMs ?? 1e3;
|
|
213
|
+
const delay = opts.backoff === "exponential" ? base * 2 ** (attempt - 1) : base;
|
|
214
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const inner = new SandboxBuilder();
|
|
218
|
+
fn(inner);
|
|
219
|
+
await flushOps(sandbox, inner._ops, ctx);
|
|
220
|
+
return;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
lastErr = err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
throw lastErr;
|
|
226
|
+
}
|
|
227
|
+
async function flushForEach(sandbox, items, fn, opts, ctx) {
|
|
228
|
+
const concurrency = opts.concurrency ?? 1;
|
|
229
|
+
if (concurrency <= 1) for (let i = 0; i < items.length; i++) {
|
|
230
|
+
const inner = new SandboxBuilder();
|
|
231
|
+
fn(inner, items[i], i);
|
|
232
|
+
await flushOps(sandbox, inner._ops, ctx);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
let idx = 0;
|
|
236
|
+
async function worker() {
|
|
237
|
+
while (idx < items.length) {
|
|
238
|
+
const i = idx++;
|
|
239
|
+
const inner = new SandboxBuilder();
|
|
240
|
+
fn(inner, items[i], i);
|
|
241
|
+
await flushOps(sandbox, inner._ops, { ...ctx });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, worker);
|
|
245
|
+
await Promise.all(workers);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/workflow-builder.ts
|
|
250
|
+
/**
|
|
251
|
+
* Lazy workflow builder. Collects stages and executes them all when `.pipe()` or
|
|
252
|
+
* `.result()` is awaited. Sandboxes are created via the `Drej` client internally
|
|
253
|
+
* — the user never manages sandbox lifecycle when using this layer.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* import { workflow } from "@drej/workflow";
|
|
258
|
+
*
|
|
259
|
+
* await workflow(client)
|
|
260
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
261
|
+
* sb.exec("npm ci")
|
|
262
|
+
* sb.checkpoint()
|
|
263
|
+
* sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" })
|
|
264
|
+
* })
|
|
265
|
+
* .pipe(process.stdout);
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
var WorkflowBuilder = class {
|
|
269
|
+
_client;
|
|
270
|
+
_stages = [];
|
|
271
|
+
constructor(client) {
|
|
272
|
+
this._client = client;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Add a sandbox stage. The `fn` callback receives a `SandboxBuilder` to
|
|
276
|
+
* queue operations synchronously. Sandboxes across multiple `.sandbox()` calls
|
|
277
|
+
* run sequentially, top-to-bottom.
|
|
278
|
+
*/
|
|
279
|
+
sandbox(opts, fn) {
|
|
280
|
+
this._stages.push({
|
|
281
|
+
type: "sandbox",
|
|
282
|
+
opts,
|
|
283
|
+
fn
|
|
284
|
+
});
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Run the same operation across multiple sandbox configurations in parallel.
|
|
289
|
+
* All sandboxes receive the same `fn` callback.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* await workflow(client)
|
|
294
|
+
* .parallel(
|
|
295
|
+
* [{ image: "node:20" }, { image: "node:22" }, { image: "node:24" }],
|
|
296
|
+
* (sb) => sb.exec("npm test"),
|
|
297
|
+
* )
|
|
298
|
+
* .pipe(process.stdout);
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
parallel(configs, fn) {
|
|
302
|
+
this._stages.push({
|
|
303
|
+
type: "parallel",
|
|
304
|
+
configs,
|
|
305
|
+
fn
|
|
306
|
+
});
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Run sandboxes in sequence, passing the previous stage's result to the next.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* await workflow(client)
|
|
315
|
+
* .sequence([
|
|
316
|
+
* { image: "node:22", run: (sb) => sb.exec("npm run build") },
|
|
317
|
+
* { image: "ubuntu:22.04", run: (sb, prev) => sb.exec("./deploy.sh") },
|
|
318
|
+
* ])
|
|
319
|
+
* .pipe(process.stdout);
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
sequence(steps) {
|
|
323
|
+
this._stages.push({
|
|
324
|
+
type: "sequence",
|
|
325
|
+
steps
|
|
326
|
+
});
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
/** Execute the workflow and pipe stdout to a writable. */
|
|
330
|
+
async pipe(writable) {
|
|
331
|
+
await this._execute(writable);
|
|
332
|
+
}
|
|
333
|
+
/** Execute the workflow and return the full result. */
|
|
334
|
+
async result() {
|
|
335
|
+
return this._execute(void 0);
|
|
336
|
+
}
|
|
337
|
+
async _execute(sink) {
|
|
338
|
+
const combined = {
|
|
339
|
+
stdout: "",
|
|
340
|
+
vars: {}
|
|
341
|
+
};
|
|
342
|
+
for (const stage of this._stages) if (stage.type === "sandbox") {
|
|
343
|
+
const result = await this._runSandbox(stage.opts, stage.fn, sink);
|
|
344
|
+
combined.stdout += result.stdout;
|
|
345
|
+
Object.assign(combined.vars, result.vars);
|
|
346
|
+
} else if (stage.type === "parallel") {
|
|
347
|
+
const results = await this._runParallel(stage.configs, stage.fn, sink);
|
|
348
|
+
for (const r of results) {
|
|
349
|
+
combined.stdout += r.stdout;
|
|
350
|
+
Object.assign(combined.vars, r.vars);
|
|
351
|
+
}
|
|
352
|
+
} else if (stage.type === "sequence") {
|
|
353
|
+
const result = await this._runSequence(stage.steps, sink);
|
|
354
|
+
combined.stdout += result.stdout;
|
|
355
|
+
Object.assign(combined.vars, result.vars);
|
|
356
|
+
}
|
|
357
|
+
return combined;
|
|
358
|
+
}
|
|
359
|
+
async _runSandbox(opts, fn, sink) {
|
|
360
|
+
const sb = new SandboxBuilder();
|
|
361
|
+
fn(sb);
|
|
362
|
+
const sandbox = await this._client.sandbox(opts);
|
|
363
|
+
try {
|
|
364
|
+
const ctx = {
|
|
365
|
+
stdout: "",
|
|
366
|
+
exitCode: 0,
|
|
367
|
+
vars: {},
|
|
368
|
+
sink
|
|
369
|
+
};
|
|
370
|
+
await flushOps(sandbox, sb._ops, ctx);
|
|
371
|
+
return {
|
|
372
|
+
stdout: ctx.stdout,
|
|
373
|
+
vars: ctx.vars
|
|
374
|
+
};
|
|
375
|
+
} finally {
|
|
376
|
+
await sandbox.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async _runParallel(configs, fn, sink) {
|
|
380
|
+
return Promise.all(configs.map((opts) => this._runSandbox(opts, fn, sink)));
|
|
381
|
+
}
|
|
382
|
+
async _runSequence(steps, sink) {
|
|
383
|
+
const combined = {
|
|
384
|
+
stdout: "",
|
|
385
|
+
vars: {}
|
|
386
|
+
};
|
|
387
|
+
let prev;
|
|
388
|
+
for (const step of steps) {
|
|
389
|
+
const opts = {
|
|
390
|
+
image: step.image,
|
|
391
|
+
resources: step.resources,
|
|
392
|
+
env: step.env,
|
|
393
|
+
timeout: step.timeout,
|
|
394
|
+
name: step.name
|
|
395
|
+
};
|
|
396
|
+
const result = await this._runSandbox(opts, (sb) => step.run(sb, prev), sink);
|
|
397
|
+
combined.stdout += result.stdout;
|
|
398
|
+
Object.assign(combined.vars, result.vars);
|
|
399
|
+
prev = result;
|
|
400
|
+
}
|
|
401
|
+
return combined;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Create a workflow builder attached to a `Drej` client.
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* ```ts
|
|
409
|
+
* import { workflow } from "@drej/workflow";
|
|
410
|
+
*
|
|
411
|
+
* await workflow(client)
|
|
412
|
+
* .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
|
|
413
|
+
* sb.exec("npm ci")
|
|
414
|
+
* sb.exec("npm test")
|
|
415
|
+
* })
|
|
416
|
+
* .pipe(process.stdout);
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
function workflow(client) {
|
|
420
|
+
return new WorkflowBuilder(client);
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
export { SandboxBuilder, WorkflowBuilder, workflow };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drej/workflow",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
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
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsdown",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"drej": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"bun-types": "1.3.14",
|
|
29
|
+
"tsdown": "0.22.3",
|
|
30
|
+
"typescript": "6.0.3",
|
|
31
|
+
"vitest": "4.1.9"
|
|
32
|
+
}
|
|
33
|
+
}
|