builderman 1.2.0 → 1.3.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/README.md +290 -197
- package/dist/graph.js +0 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/modules/signal-handler.d.ts +13 -0
- package/dist/modules/signal-handler.js +38 -0
- package/dist/modules/task-executor.d.ts +25 -0
- package/dist/modules/task-executor.js +313 -0
- package/dist/modules/teardown-manager.d.ts +22 -0
- package/dist/modules/teardown-manager.js +157 -0
- package/dist/pipeline-error.d.ts +11 -0
- package/dist/pipeline-error.js +50 -0
- package/dist/pipeline.d.ts +0 -12
- package/dist/pipeline.js +164 -481
- package/dist/scheduler.d.ts +4 -4
- package/dist/scheduler.js +3 -3
- package/dist/task.d.ts +0 -5
- package/dist/task.js +5 -17
- package/dist/types.d.ts +208 -25
- package/package.json +1 -1
- package/dist/pipeline.test.d.ts +0 -1
package/dist/pipeline.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
2
|
import { $TASK_INTERNAL } from "./constants.js";
|
|
5
3
|
import { createTaskGraph } from "./graph.js";
|
|
4
|
+
import { PipelineError } from "./pipeline-error.js";
|
|
6
5
|
import { createScheduler } from "./scheduler.js";
|
|
7
6
|
import { task } from "./task.js";
|
|
8
7
|
import { validateTasks } from "./util.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import { createTeardownManager } from "./modules/teardown-manager.js";
|
|
9
|
+
import { createSignalHandler } from "./modules/signal-handler.js";
|
|
10
|
+
import { executeTask } from "./modules/task-executor.js";
|
|
12
11
|
// Store tasks for each pipeline (for nested pipeline skip tracking)
|
|
13
12
|
const pipelineTasksCache = new WeakMap();
|
|
14
13
|
/**
|
|
@@ -21,27 +20,21 @@ const pipelineTasksCache = new WeakMap();
|
|
|
21
20
|
* await pipeline([task1, task2]).run()
|
|
22
21
|
*/
|
|
23
22
|
export function pipeline(tasks) {
|
|
24
|
-
const
|
|
23
|
+
const tasksClone = [...tasks];
|
|
24
|
+
validateTasks(tasksClone);
|
|
25
|
+
const graph = createTaskGraph(tasksClone);
|
|
25
26
|
graph.validate();
|
|
26
27
|
graph.simplify();
|
|
27
28
|
const pipelineImpl = {
|
|
28
|
-
toTask(
|
|
29
|
-
validateTasks(config.dependencies);
|
|
29
|
+
toTask({ name, dependencies }) {
|
|
30
30
|
const syntheticTask = task({
|
|
31
|
-
name
|
|
31
|
+
name,
|
|
32
32
|
commands: {},
|
|
33
|
-
cwd: ".",
|
|
34
|
-
dependencies
|
|
33
|
+
cwd: ".",
|
|
34
|
+
dependencies,
|
|
35
35
|
});
|
|
36
|
-
// Mark this task as a pipeline task
|
|
36
|
+
// Mark this task as a pipeline task so it can be detected by the executor
|
|
37
37
|
syntheticTask[$TASK_INTERNAL].pipeline = pipelineImpl;
|
|
38
|
-
// Cache this conversion
|
|
39
|
-
let cache = pipelineTaskCache.get(pipelineImpl);
|
|
40
|
-
if (!cache) {
|
|
41
|
-
cache = new Map();
|
|
42
|
-
pipelineTaskCache.set(pipelineImpl, cache);
|
|
43
|
-
}
|
|
44
|
-
cache.set(config.name, syntheticTask);
|
|
45
38
|
return syntheticTask;
|
|
46
39
|
},
|
|
47
40
|
async run(config) {
|
|
@@ -49,144 +42,100 @@ export function pipeline(tasks) {
|
|
|
49
42
|
const signal = config?.signal;
|
|
50
43
|
const runningTasks = new Map();
|
|
51
44
|
const runningPipelines = new Map();
|
|
52
|
-
const teardownCommands = new Map();
|
|
53
45
|
let failed = false;
|
|
46
|
+
const command = config?.command ??
|
|
47
|
+
(process.env.NODE_ENV === "production" ? "build" : "dev");
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
// Initialize task stats tracking
|
|
50
|
+
const taskStats = new Map();
|
|
51
|
+
for (const [taskId, node] of graph.nodes) {
|
|
52
|
+
const task = node.task;
|
|
53
|
+
taskStats.set(taskId, {
|
|
54
|
+
id: taskId,
|
|
55
|
+
name: task.name,
|
|
56
|
+
status: "pending",
|
|
57
|
+
dependencies: Array.from(node.dependencies),
|
|
58
|
+
dependents: Array.from(node.dependents),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Helper to update task status
|
|
62
|
+
const updateTaskStatus = (taskId, updates) => {
|
|
63
|
+
const stats = taskStats.get(taskId);
|
|
64
|
+
if (stats) {
|
|
65
|
+
Object.assign(stats, updates);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
54
68
|
// Check if signal is already aborted
|
|
55
69
|
if (signal?.aborted) {
|
|
56
|
-
|
|
70
|
+
const error = new PipelineError("Aborted", PipelineError.Aborted);
|
|
71
|
+
return buildResult(error, startedAt, command, taskStats, "aborted");
|
|
57
72
|
}
|
|
58
73
|
const scheduler = createScheduler(graph);
|
|
59
74
|
let completionResolver = null;
|
|
60
|
-
|
|
61
|
-
const completionPromise = new Promise((resolve, reject) => {
|
|
75
|
+
const completionPromise = new Promise((resolve) => {
|
|
62
76
|
completionResolver = resolve;
|
|
63
|
-
completionRejector = reject;
|
|
64
77
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const teardownProcess = spawnFn(teardown.command, {
|
|
75
|
-
cwd: teardown.cwd,
|
|
76
|
-
stdio: "inherit",
|
|
77
|
-
shell: true,
|
|
78
|
-
});
|
|
79
|
-
let resolved = false;
|
|
80
|
-
const resolveOnce = () => {
|
|
81
|
-
if (!resolved) {
|
|
82
|
-
resolved = true;
|
|
83
|
-
resolve();
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
teardownProcess.on("error", (error) => {
|
|
87
|
-
const teardownError = new Error(`[${teardown.taskName}] Teardown failed: ${error.message}`);
|
|
88
|
-
config?.onTaskTeardownError?.(teardown.taskName, teardownError);
|
|
89
|
-
resolveOnce();
|
|
90
|
-
});
|
|
91
|
-
teardownProcess.on("exit", (code) => {
|
|
92
|
-
if (code !== 0) {
|
|
93
|
-
const teardownError = new Error(`[${teardown.taskName}] Teardown failed with exit code ${code ?? 1}`);
|
|
94
|
-
config?.onTaskTeardownError?.(teardown.taskName, teardownError);
|
|
95
|
-
}
|
|
96
|
-
resolveOnce();
|
|
97
|
-
});
|
|
78
|
+
// Initialize teardown manager with stats tracking
|
|
79
|
+
const teardownManager = createTeardownManager({
|
|
80
|
+
...config,
|
|
81
|
+
spawn: spawnFn,
|
|
82
|
+
updateTaskTeardownStatus: (taskId, status, error) => {
|
|
83
|
+
const stats = taskStats.get(taskId);
|
|
84
|
+
if (stats) {
|
|
85
|
+
stats.teardown = { status, error };
|
|
98
86
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
};
|
|
106
|
-
const executeAllTeardowns = async () => {
|
|
107
|
-
// Execute teardowns in reverse dependency order
|
|
108
|
-
// Tasks with dependents should be torn down before their dependencies
|
|
109
|
-
const taskIdsWithTeardown = Array.from(teardownCommands.keys());
|
|
110
|
-
if (taskIdsWithTeardown.length === 0) {
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const advanceScheduler = (input) => {
|
|
90
|
+
// Check if signal is aborted before advancing scheduler
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
failPipeline(new PipelineError("Aborted", PipelineError.Aborted));
|
|
111
93
|
return;
|
|
112
94
|
}
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
95
|
+
let result = input ? scheduler.next(input) : scheduler.next();
|
|
96
|
+
// If we passed skip/complete input and got "idle", the generator processed it
|
|
97
|
+
// but returned the old yield. Call next() again to get the result after processing.
|
|
98
|
+
if (input &&
|
|
99
|
+
(input.type === "skip" || input.type === "complete") &&
|
|
100
|
+
result.value?.type === "idle") {
|
|
101
|
+
result = scheduler.next();
|
|
120
102
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// If api depends on db, we want: api first, then db
|
|
127
|
-
// This is the reverse of normal execution order
|
|
128
|
-
// Count how many dependencies each task has (within the teardown set)
|
|
129
|
-
const dependencyCount = new Map();
|
|
130
|
-
for (const taskId of taskIds) {
|
|
131
|
-
const node = graph.nodes.get(taskId);
|
|
132
|
-
if (node) {
|
|
133
|
-
let count = 0;
|
|
134
|
-
for (const depId of node.dependencies) {
|
|
135
|
-
if (taskIdSet.has(depId)) {
|
|
136
|
-
count++;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
dependencyCount.set(taskId, count);
|
|
103
|
+
while (true) {
|
|
104
|
+
// Check signal again in the loop
|
|
105
|
+
if (signal?.aborted) {
|
|
106
|
+
failPipeline(new PipelineError("Aborted", PipelineError.Aborted));
|
|
107
|
+
return;
|
|
140
108
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const queue = [];
|
|
147
|
-
// Find leaf nodes (tasks with no dependencies in teardown set)
|
|
148
|
-
for (const taskId of taskIds) {
|
|
149
|
-
if (dependencyCount.get(taskId) === 0) {
|
|
150
|
-
queue.push(taskId);
|
|
109
|
+
const event = result.value;
|
|
110
|
+
const isFinished = result.done && result.value.type === "done";
|
|
111
|
+
if (isFinished) {
|
|
112
|
+
completionResolver?.(null);
|
|
113
|
+
return;
|
|
151
114
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const taskId = queue.shift();
|
|
156
|
-
if (visited.has(taskId))
|
|
115
|
+
if (event.type === "run") {
|
|
116
|
+
startTask(graph.nodes.get(event.taskId).task);
|
|
117
|
+
result = scheduler.next();
|
|
157
118
|
continue;
|
|
158
|
-
visited.add(taskId);
|
|
159
|
-
// Add to front (so we get reverse order: dependents before dependencies)
|
|
160
|
-
result.unshift(taskId);
|
|
161
|
-
// Find tasks that depend on this one (dependents)
|
|
162
|
-
const node = graph.nodes.get(taskId);
|
|
163
|
-
if (node) {
|
|
164
|
-
for (const dependentId of node.dependents) {
|
|
165
|
-
if (taskIdSet.has(dependentId) && !visited.has(dependentId)) {
|
|
166
|
-
const currentCount = dependencyCount.get(dependentId) ?? 0;
|
|
167
|
-
const newCount = currentCount - 1;
|
|
168
|
-
dependencyCount.set(dependentId, newCount);
|
|
169
|
-
// When a dependent has no more dependencies to wait for, add it to queue
|
|
170
|
-
if (newCount === 0) {
|
|
171
|
-
queue.push(dependentId);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
119
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const taskId of taskIds) {
|
|
179
|
-
if (!visited.has(taskId)) {
|
|
180
|
-
result.unshift(taskId);
|
|
120
|
+
if (event.type === "idle") {
|
|
121
|
+
return;
|
|
181
122
|
}
|
|
182
123
|
}
|
|
183
|
-
return result;
|
|
184
124
|
};
|
|
185
125
|
const failPipeline = async (error) => {
|
|
186
126
|
if (failed)
|
|
187
127
|
return;
|
|
188
128
|
failed = true;
|
|
189
|
-
|
|
129
|
+
// Mark running tasks as aborted
|
|
130
|
+
for (const [taskId, child] of runningTasks.entries()) {
|
|
131
|
+
updateTaskStatus(taskId, {
|
|
132
|
+
status: "aborted",
|
|
133
|
+
finishedAt: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
const stats = taskStats.get(taskId);
|
|
136
|
+
if (stats && stats.startedAt) {
|
|
137
|
+
stats.durationMs = stats.finishedAt - stats.startedAt;
|
|
138
|
+
}
|
|
190
139
|
try {
|
|
191
140
|
child.kill("SIGTERM");
|
|
192
141
|
}
|
|
@@ -202,379 +151,113 @@ export function pipeline(tasks) {
|
|
|
202
151
|
// Execute all teardown commands and wait for them to complete
|
|
203
152
|
// Even if teardowns fail, we still want to reject the pipeline
|
|
204
153
|
try {
|
|
205
|
-
await
|
|
154
|
+
await teardownManager.executeAll(graph);
|
|
206
155
|
}
|
|
207
156
|
catch (teardownError) {
|
|
208
157
|
// Teardown errors are already reported via onTaskTeardownError
|
|
209
158
|
// We continue to reject the pipeline with the original error
|
|
210
159
|
}
|
|
211
|
-
|
|
212
|
-
completionRejector?.(error);
|
|
160
|
+
completionResolver?.(error);
|
|
213
161
|
};
|
|
214
162
|
const startTask = (task) => {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// Get total tasks from nested pipeline
|
|
229
|
-
const nestedTasks = pipelineTasksCache.get(nestedPipeline);
|
|
230
|
-
const nestedTotalTasks = nestedTasks
|
|
231
|
-
? createTaskGraph(nestedTasks).nodes.size
|
|
232
|
-
: 0;
|
|
233
|
-
// Mark as ready immediately (pipeline entry nodes will handle their own ready state)
|
|
234
|
-
advanceScheduler({ type: "ready", taskId });
|
|
235
|
-
config?.onTaskBegin?.(taskName);
|
|
236
|
-
// Create an abort controller to stop the nested pipeline if needed
|
|
237
|
-
let pipelineStopped = false;
|
|
238
|
-
const stopPipeline = () => {
|
|
239
|
-
pipelineStopped = true;
|
|
240
|
-
// The nested pipeline will continue running, but we've marked it as stopped
|
|
241
|
-
// In a more sophisticated implementation, we could propagate stop signals
|
|
242
|
-
};
|
|
243
|
-
runningPipelines.set(taskId, { stop: stopPipeline });
|
|
244
|
-
// Run the nested pipeline with signal propagation
|
|
245
|
-
nestedPipeline
|
|
246
|
-
.run({
|
|
247
|
-
spawn: spawnFn,
|
|
248
|
-
command: config?.command,
|
|
249
|
-
strict: config?.strict,
|
|
250
|
-
signal, // Pass signal to nested pipeline
|
|
251
|
-
onTaskBegin: (nestedTaskName) => {
|
|
252
|
-
if (pipelineStopped)
|
|
253
|
-
return;
|
|
254
|
-
config?.onTaskBegin?.(`${taskName}:${nestedTaskName}`);
|
|
255
|
-
},
|
|
256
|
-
onTaskComplete: (nestedTaskName) => {
|
|
257
|
-
if (pipelineStopped)
|
|
258
|
-
return;
|
|
259
|
-
nestedCompletedCount++;
|
|
260
|
-
config?.onTaskComplete?.(`${taskName}:${nestedTaskName}`);
|
|
261
|
-
},
|
|
262
|
-
onTaskSkipped: (nestedTaskName, mode) => {
|
|
263
|
-
if (pipelineStopped)
|
|
264
|
-
return;
|
|
265
|
-
nestedSkippedCount++;
|
|
266
|
-
config?.onTaskSkipped?.(`${taskName}:${nestedTaskName}`, mode);
|
|
267
|
-
},
|
|
268
|
-
onPipelineError: (error) => {
|
|
269
|
-
if (pipelineStopped)
|
|
270
|
-
return;
|
|
271
|
-
runningPipelines.delete(taskId);
|
|
272
|
-
// error is already a PipelineError
|
|
273
|
-
failPipeline(error);
|
|
274
|
-
},
|
|
275
|
-
onPipelineComplete: () => {
|
|
276
|
-
if (pipelineStopped)
|
|
277
|
-
return;
|
|
278
|
-
runningPipelines.delete(taskId);
|
|
279
|
-
// Determine nested pipeline result based on skip behavior:
|
|
280
|
-
// - If all inner tasks are skipped → outer task is skipped
|
|
281
|
-
// - If some run, some skip → outer task is completed
|
|
282
|
-
// - If any fail → outer task fails (handled in onPipelineError)
|
|
283
|
-
const commandName = config?.command ?? process.env.NODE_ENV === "production"
|
|
284
|
-
? "build"
|
|
285
|
-
: "dev";
|
|
286
|
-
if (nestedSkippedCount === nestedTotalTasks &&
|
|
287
|
-
nestedTotalTasks > 0) {
|
|
288
|
-
// All tasks were skipped
|
|
289
|
-
console.log(`[${taskName}] skipped (all nested tasks skipped)`);
|
|
290
|
-
config?.onTaskSkipped?.(taskName, commandName);
|
|
291
|
-
setImmediate(() => {
|
|
292
|
-
advanceScheduler({ type: "skip", taskId });
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
// Some tasks ran (and completed successfully)
|
|
297
|
-
config?.onTaskComplete?.(taskName);
|
|
298
|
-
advanceScheduler({ type: "complete", taskId });
|
|
299
|
-
}
|
|
300
|
-
},
|
|
301
|
-
})
|
|
302
|
-
.catch((error) => {
|
|
303
|
-
if (pipelineStopped)
|
|
304
|
-
return;
|
|
305
|
-
runningPipelines.delete(taskId);
|
|
306
|
-
failPipeline(error);
|
|
307
|
-
});
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
// Regular task execution
|
|
311
|
-
const commandName = config?.command ?? process.env.NODE_ENV === "production"
|
|
312
|
-
? "build"
|
|
313
|
-
: "dev";
|
|
314
|
-
const commandConfig = task[$TASK_INTERNAL].commands[commandName];
|
|
315
|
-
// Check if command exists
|
|
316
|
-
if (commandConfig === undefined) {
|
|
317
|
-
const allowSkip = task[$TASK_INTERNAL].allowSkip ?? false;
|
|
318
|
-
const strict = config?.strict ?? false;
|
|
319
|
-
// If strict mode and not explicitly allowed to skip, fail
|
|
320
|
-
if (strict && !allowSkip) {
|
|
321
|
-
failPipeline(new PipelineError(`[${taskName}] No command for "${commandName}" and strict mode is enabled`, PipelineError.TaskFailed));
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
// Skip the task
|
|
325
|
-
console.log(`[${taskName}] skipped "${commandName}"`);
|
|
326
|
-
config?.onTaskSkipped?.(taskName, commandName);
|
|
327
|
-
// Mark as skipped - this satisfies dependencies and unblocks dependents
|
|
328
|
-
// Use setImmediate to ensure scheduler is at idle yield before receiving skip
|
|
329
|
-
setImmediate(() => {
|
|
330
|
-
advanceScheduler({ type: "skip", taskId });
|
|
331
|
-
});
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
const command = typeof commandConfig === "string" ? commandConfig : commandConfig.run;
|
|
335
|
-
const readyWhen = typeof commandConfig === "string"
|
|
336
|
-
? undefined
|
|
337
|
-
: commandConfig.readyWhen;
|
|
338
|
-
const readyTimeout = typeof commandConfig === "string"
|
|
339
|
-
? Infinity
|
|
340
|
-
: commandConfig.readyTimeout ?? Infinity;
|
|
341
|
-
const teardown = typeof commandConfig === "string" ? undefined : commandConfig.teardown;
|
|
342
|
-
const { cwd } = task[$TASK_INTERNAL];
|
|
343
|
-
const taskCwd = path.isAbsolute(cwd)
|
|
344
|
-
? cwd
|
|
345
|
-
: path.resolve(process.cwd(), cwd);
|
|
346
|
-
if (!fs.existsSync(taskCwd)) {
|
|
347
|
-
failPipeline(new PipelineError(`[${taskName}] Working directory does not exist: ${taskCwd}`, PipelineError.InvalidTask));
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const accumulatedPath = [
|
|
351
|
-
path.join(taskCwd, "node_modules", ".bin"),
|
|
352
|
-
path.join(process.cwd(), "node_modules", ".bin"),
|
|
353
|
-
process.env.PATH,
|
|
354
|
-
]
|
|
355
|
-
.filter(Boolean)
|
|
356
|
-
.join(process.platform === "win32" ? ";" : ":");
|
|
357
|
-
const env = {
|
|
358
|
-
...process.env,
|
|
359
|
-
PATH: accumulatedPath,
|
|
360
|
-
Path: accumulatedPath,
|
|
361
|
-
};
|
|
362
|
-
const child = spawnFn(command, {
|
|
363
|
-
cwd: taskCwd,
|
|
364
|
-
stdio: ["inherit", "pipe", "pipe"],
|
|
365
|
-
shell: true,
|
|
366
|
-
env,
|
|
163
|
+
executeTask(task, {
|
|
164
|
+
spawn: spawnFn,
|
|
165
|
+
signal,
|
|
166
|
+
config,
|
|
167
|
+
graph,
|
|
168
|
+
runningTasks,
|
|
169
|
+
runningPipelines,
|
|
170
|
+
teardownManager,
|
|
171
|
+
pipelineTasksCache,
|
|
172
|
+
failPipeline,
|
|
173
|
+
advanceScheduler,
|
|
174
|
+
updateTaskStatus,
|
|
175
|
+
taskStats,
|
|
367
176
|
});
|
|
368
|
-
runningTasks.set(taskId, child);
|
|
369
|
-
// Store teardown command if provided
|
|
370
|
-
if (teardown) {
|
|
371
|
-
teardownCommands.set(taskId, {
|
|
372
|
-
command: teardown,
|
|
373
|
-
cwd: taskCwd,
|
|
374
|
-
taskName,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
config?.onTaskBegin?.(taskName);
|
|
378
|
-
let didMarkReady = false;
|
|
379
|
-
let readyTimeoutId = null;
|
|
380
|
-
if (!readyWhen) {
|
|
381
|
-
advanceScheduler({ type: "ready", taskId });
|
|
382
|
-
didMarkReady = true;
|
|
383
|
-
}
|
|
384
|
-
else if (readyTimeout !== Infinity) {
|
|
385
|
-
// Set up timeout for readyWhen condition
|
|
386
|
-
readyTimeoutId = setTimeout(() => {
|
|
387
|
-
if (!didMarkReady) {
|
|
388
|
-
failPipeline(new PipelineError(`[${taskName}] Task did not become ready within ${readyTimeout}ms`, PipelineError.TaskFailed));
|
|
389
|
-
}
|
|
390
|
-
}, readyTimeout);
|
|
391
|
-
}
|
|
392
|
-
let output = "";
|
|
393
|
-
child.stdout?.on("data", (buf) => {
|
|
394
|
-
// Check if signal is aborted before processing stdout
|
|
395
|
-
if (signal?.aborted) {
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const chunk = buf.toString();
|
|
399
|
-
output += chunk;
|
|
400
|
-
process.stdout.write(chunk);
|
|
401
|
-
if (!didMarkReady && readyWhen && readyWhen(output)) {
|
|
402
|
-
if (readyTimeoutId) {
|
|
403
|
-
clearTimeout(readyTimeoutId);
|
|
404
|
-
readyTimeoutId = null;
|
|
405
|
-
}
|
|
406
|
-
advanceScheduler({ type: "ready", taskId });
|
|
407
|
-
didMarkReady = true;
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
child.stderr?.on("data", (buf) => {
|
|
411
|
-
process.stderr.write(buf);
|
|
412
|
-
});
|
|
413
|
-
child.on("error", (error) => {
|
|
414
|
-
// Task failed before entering running state, so don't execute teardown
|
|
415
|
-
// Remove teardown from map since it was never actually running
|
|
416
|
-
teardownCommands.delete(taskId);
|
|
417
|
-
// Clear ready timeout if it exists
|
|
418
|
-
if (readyTimeoutId) {
|
|
419
|
-
clearTimeout(readyTimeoutId);
|
|
420
|
-
readyTimeoutId = null;
|
|
421
|
-
}
|
|
422
|
-
failPipeline(new PipelineError(`[${taskName}] Failed to start: ${error.message}`, PipelineError.TaskFailed));
|
|
423
|
-
});
|
|
424
|
-
child.on("exit", (code) => {
|
|
425
|
-
runningTasks.delete(taskId);
|
|
426
|
-
// Clear ready timeout if it exists
|
|
427
|
-
if (readyTimeoutId) {
|
|
428
|
-
clearTimeout(readyTimeoutId);
|
|
429
|
-
readyTimeoutId = null;
|
|
430
|
-
}
|
|
431
|
-
// Don't execute teardown immediately - it will be executed in reverse dependency order
|
|
432
|
-
// when the pipeline completes or fails
|
|
433
|
-
if (code !== 0) {
|
|
434
|
-
failPipeline(new PipelineError(`[${taskName}] Task failed with exit code ${code ?? 1}`, PipelineError.TaskFailed));
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
config?.onTaskComplete?.(taskName);
|
|
438
|
-
// 🔑 Notify scheduler and drain newly runnable tasks
|
|
439
|
-
advanceScheduler({ type: "complete", taskId });
|
|
440
|
-
});
|
|
441
|
-
};
|
|
442
|
-
const advanceScheduler = (input) => {
|
|
443
|
-
// Check if signal is aborted before advancing scheduler
|
|
444
|
-
if (signal?.aborted) {
|
|
445
|
-
failPipeline(new PipelineError("Aborted", PipelineError.Aborted));
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
let result = input ? scheduler.next(input) : scheduler.next();
|
|
449
|
-
// If we passed skip/complete input and got "idle", the generator processed it
|
|
450
|
-
// but returned the old yield. Call next() again to get the result after processing.
|
|
451
|
-
if (input &&
|
|
452
|
-
(input.type === "skip" || input.type === "complete") &&
|
|
453
|
-
result.value?.type === "idle") {
|
|
454
|
-
result = scheduler.next();
|
|
455
|
-
}
|
|
456
|
-
while (true) {
|
|
457
|
-
// Check signal again in the loop
|
|
458
|
-
if (signal?.aborted) {
|
|
459
|
-
failPipeline(new PipelineError("Aborted", PipelineError.Aborted));
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
const event = result.value;
|
|
463
|
-
const isFinished = result.done && result.value.type === "done";
|
|
464
|
-
if (isFinished) {
|
|
465
|
-
config?.onPipelineComplete?.();
|
|
466
|
-
completionResolver?.();
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
if (event.type === "run") {
|
|
470
|
-
startTask(graph.nodes.get(event.taskId).task);
|
|
471
|
-
result = scheduler.next();
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
if (event.type === "idle") {
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
177
|
};
|
|
479
|
-
//
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
"
|
|
483
|
-
|
|
484
|
-
"SIGBREAK",
|
|
485
|
-
].map((sig) => {
|
|
486
|
-
const handleSignal = () => {
|
|
487
|
-
failPipeline(new PipelineError(`Received ${sig}`, PipelineError.ProcessTerminated));
|
|
488
|
-
};
|
|
489
|
-
process.once(sig, handleSignal);
|
|
490
|
-
return () => {
|
|
491
|
-
process.removeListener(sig, handleSignal);
|
|
492
|
-
};
|
|
178
|
+
// Initialize signal handler
|
|
179
|
+
const signalHandler = createSignalHandler({
|
|
180
|
+
abortSignal: signal,
|
|
181
|
+
onAborted: () => failPipeline(new PipelineError("Aborted", PipelineError.Aborted)),
|
|
182
|
+
onProcessTerminated: (message) => new PipelineError(message, PipelineError.ProcessTerminated),
|
|
493
183
|
});
|
|
494
|
-
// Handle abort signal if provided
|
|
495
|
-
let signalCleanup = null;
|
|
496
|
-
if (signal) {
|
|
497
|
-
const handleAbort = () => {
|
|
498
|
-
failPipeline(new PipelineError("Aborted", PipelineError.Aborted));
|
|
499
|
-
};
|
|
500
|
-
signal.addEventListener("abort", handleAbort);
|
|
501
|
-
signalCleanup = () => {
|
|
502
|
-
signal.removeEventListener("abort", handleAbort);
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
184
|
// 🚀 Kick off initial runnable tasks
|
|
506
185
|
advanceScheduler();
|
|
507
|
-
await completionPromise
|
|
508
|
-
|
|
509
|
-
// Pipeline completed successfully - execute any remaining teardowns
|
|
186
|
+
const error = await completionPromise.then(async (err) => {
|
|
187
|
+
// Pipeline completed - execute any remaining teardowns
|
|
510
188
|
// (for tasks that completed successfully) and wait for them to complete
|
|
511
189
|
// Even if teardowns fail, the pipeline still resolves successfully
|
|
512
190
|
// (teardown errors are reported via onTaskTeardownError)
|
|
513
191
|
try {
|
|
514
|
-
await
|
|
192
|
+
await teardownManager.executeAll(graph);
|
|
515
193
|
}
|
|
516
194
|
catch (teardownError) {
|
|
517
195
|
// Teardown errors are already reported via onTaskTeardownError
|
|
518
196
|
// The pipeline still resolves successfully
|
|
519
197
|
}
|
|
520
|
-
|
|
521
|
-
.finally(() => {
|
|
522
|
-
processTerminationListenerCleanups.forEach((cleanup) => cleanup());
|
|
523
|
-
signalCleanup?.();
|
|
198
|
+
return err;
|
|
524
199
|
});
|
|
200
|
+
signalHandler.cleanup();
|
|
201
|
+
const status = error
|
|
202
|
+
? error.code === PipelineError.Aborted
|
|
203
|
+
? "aborted"
|
|
204
|
+
: "failed"
|
|
205
|
+
: "success";
|
|
206
|
+
return buildResult(error, startedAt, command, taskStats, status);
|
|
525
207
|
},
|
|
526
208
|
};
|
|
527
209
|
// Store tasks for nested pipeline skip tracking
|
|
528
|
-
pipelineTasksCache.set(pipelineImpl,
|
|
210
|
+
pipelineTasksCache.set(pipelineImpl, tasksClone);
|
|
529
211
|
return pipelineImpl;
|
|
530
212
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
213
|
+
function buildResult(error, startedAt, command, taskStats, status) {
|
|
214
|
+
const finishedAt = Date.now();
|
|
215
|
+
const durationMs = finishedAt - startedAt;
|
|
216
|
+
// Convert task stats map to record
|
|
217
|
+
const tasks = {};
|
|
218
|
+
for (const [taskId, stats] of taskStats) {
|
|
219
|
+
tasks[taskId] = stats;
|
|
220
|
+
}
|
|
221
|
+
// Calculate summary
|
|
222
|
+
let completed = 0;
|
|
223
|
+
let failed = 0;
|
|
224
|
+
let skipped = 0;
|
|
225
|
+
let running = 0;
|
|
226
|
+
for (const stats of taskStats.values()) {
|
|
227
|
+
switch (stats.status) {
|
|
228
|
+
case "completed":
|
|
229
|
+
completed++;
|
|
230
|
+
break;
|
|
231
|
+
case "failed":
|
|
232
|
+
failed++;
|
|
233
|
+
break;
|
|
234
|
+
case "skipped":
|
|
235
|
+
skipped++;
|
|
236
|
+
break;
|
|
237
|
+
case "running":
|
|
238
|
+
running++;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const pipelineStats = {
|
|
243
|
+
command,
|
|
244
|
+
startedAt,
|
|
245
|
+
finishedAt,
|
|
246
|
+
durationMs,
|
|
247
|
+
status,
|
|
248
|
+
tasks,
|
|
249
|
+
summary: {
|
|
250
|
+
total: taskStats.size,
|
|
251
|
+
completed,
|
|
252
|
+
failed,
|
|
253
|
+
skipped,
|
|
254
|
+
running,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
if (error) {
|
|
258
|
+
return { ok: false, error, stats: pipelineStats };
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
return { ok: true, error: null, stats: pipelineStats };
|
|
549
262
|
}
|
|
550
263
|
}
|
|
551
|
-
Object.defineProperty(PipelineError, "Aborted", {
|
|
552
|
-
enumerable: true,
|
|
553
|
-
configurable: true,
|
|
554
|
-
writable: true,
|
|
555
|
-
value: 0
|
|
556
|
-
});
|
|
557
|
-
Object.defineProperty(PipelineError, "ProcessTerminated", {
|
|
558
|
-
enumerable: true,
|
|
559
|
-
configurable: true,
|
|
560
|
-
writable: true,
|
|
561
|
-
value: 1
|
|
562
|
-
});
|
|
563
|
-
Object.defineProperty(PipelineError, "TaskFailed", {
|
|
564
|
-
enumerable: true,
|
|
565
|
-
configurable: true,
|
|
566
|
-
writable: true,
|
|
567
|
-
value: 2
|
|
568
|
-
});
|
|
569
|
-
Object.defineProperty(PipelineError, "InvalidSignal", {
|
|
570
|
-
enumerable: true,
|
|
571
|
-
configurable: true,
|
|
572
|
-
writable: true,
|
|
573
|
-
value: 3
|
|
574
|
-
});
|
|
575
|
-
Object.defineProperty(PipelineError, "InvalidTask", {
|
|
576
|
-
enumerable: true,
|
|
577
|
-
configurable: true,
|
|
578
|
-
writable: true,
|
|
579
|
-
value: 4
|
|
580
|
-
});
|