builderman 1.4.0 → 1.5.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 CHANGED
@@ -19,7 +19,8 @@ It is designed for monorepos, long-running development processes, and CI/CD pipe
19
19
  > - [Environment Variables](#environment-variables)
20
20
  > - [Dependencies](#dependencies)
21
21
  > - [Pipelines](#pipelines)
22
- > - [Pipeline Composition](#pipeline-composition)
22
+ > - [Concurrency Control](#concurrency-control)
23
+ > - [Pipeline Composition](#pipeline-composition)
23
24
  > - [Error Handling Guarantees](#error-handling-guarantees)
24
25
  > - [Cancellation](#cancellation)
25
26
  > - [Teardown](#teardown)
@@ -256,6 +257,31 @@ const result = await pipeline([libTask, consumerTask]).run({
256
257
  })
257
258
  ```
258
259
 
260
+ #### Concurrency Control
261
+
262
+ By default, pipelines run as many tasks concurrently as possible (limited only by dependencies). You can limit concurrent execution using `maxConcurrency`:
263
+
264
+ ```ts
265
+ const result = await pipeline([task1, task2, task3, task4, task5]).run({
266
+ maxConcurrency: 2, // At most 2 tasks will run simultaneously
267
+ })
268
+ ```
269
+
270
+ When `maxConcurrency` is set:
271
+
272
+ - Tasks that are ready to run (dependencies satisfied) will start up to the limit
273
+ - As tasks complete, new ready tasks will start to maintain the concurrency limit
274
+ - Dependencies are still respected — a task won't start until its dependencies complete
275
+
276
+ This is useful for:
277
+
278
+ - Limiting resource usage (CPU, memory, network)
279
+ - Controlling database connection pools
280
+ - Managing API rate limits
281
+ - Reducing system load in CI environments
282
+
283
+ If `maxConcurrency` is not specified, there is no limit (tasks run concurrently as dependencies allow).
284
+
259
285
  ---
260
286
 
261
287
  ### Pipeline Composition
@@ -306,16 +332,19 @@ if (!result.ok) {
306
332
  console.error("Pipeline was cancelled")
307
333
  break
308
334
  case PipelineError.TaskFailed:
309
- console.error(`Task failed: ${result.error.message}`)
335
+ console.error("Task failed:", result.error.message)
336
+ break
337
+ case PipelineError.TaskReadyTimeout:
338
+ console.error("Task was not ready in time:", result.error.message)
339
+ break
340
+ case PipelineError.TaskCompletedTimeout:
341
+ console.error("Task did not complete in time:", result.error.message)
310
342
  break
311
343
  case PipelineError.ProcessTerminated:
312
- console.error("Process was terminated")
344
+ console.error("Process terminated:", result.error.message)
313
345
  break
314
346
  case PipelineError.InvalidTask:
315
- console.error(`Invalid task configuration: ${result.error.message}`)
316
- break
317
- case PipelineError.InvalidSignal:
318
- console.error("Invalid abort signal")
347
+ console.error("Invalid task configuration:", result.error.message)
319
348
  break
320
349
  }
321
350
  }
@@ -0,0 +1,12 @@
1
+ export type PipelineErrorCode = typeof PipelineError.Aborted | typeof PipelineError.ProcessTerminated | typeof PipelineError.TaskFailed | typeof PipelineError.TaskCompletedTimeout | typeof PipelineError.TaskReadyTimeout | typeof PipelineError.InvalidTask;
2
+ export declare class PipelineError extends Error {
3
+ readonly code: PipelineErrorCode;
4
+ readonly taskName?: string;
5
+ constructor(message: string, code: PipelineErrorCode, taskName?: string);
6
+ static Aborted: "aborted";
7
+ static ProcessTerminated: "process-terminated";
8
+ static TaskFailed: "task-failed";
9
+ static TaskReadyTimeout: "task-ready-timeout";
10
+ static TaskCompletedTimeout: "task-completed-timeout";
11
+ static InvalidTask: "invalid-task";
12
+ }
@@ -22,29 +22,35 @@ Object.defineProperty(PipelineError, "Aborted", {
22
22
  enumerable: true,
23
23
  configurable: true,
24
24
  writable: true,
25
- value: 0
25
+ value: "aborted"
26
26
  });
27
27
  Object.defineProperty(PipelineError, "ProcessTerminated", {
28
28
  enumerable: true,
29
29
  configurable: true,
30
30
  writable: true,
31
- value: 1
31
+ value: "process-terminated"
32
32
  });
33
33
  Object.defineProperty(PipelineError, "TaskFailed", {
34
34
  enumerable: true,
35
35
  configurable: true,
36
36
  writable: true,
37
- value: 2
37
+ value: "task-failed"
38
38
  });
39
- Object.defineProperty(PipelineError, "InvalidSignal", {
39
+ Object.defineProperty(PipelineError, "TaskReadyTimeout", {
40
40
  enumerable: true,
41
41
  configurable: true,
42
42
  writable: true,
43
- value: 3
43
+ value: "task-ready-timeout"
44
+ });
45
+ Object.defineProperty(PipelineError, "TaskCompletedTimeout", {
46
+ enumerable: true,
47
+ configurable: true,
48
+ writable: true,
49
+ value: "task-completed-timeout"
44
50
  });
45
51
  Object.defineProperty(PipelineError, "InvalidTask", {
46
52
  enumerable: true,
47
53
  configurable: true,
48
54
  writable: true,
49
- value: 4
55
+ value: "invalid-task"
50
56
  });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { task } from "./task.js";
2
2
  export { pipeline } from "./pipeline.js";
3
- export { PipelineError, type PipelineErrorCode } from "./pipeline-error.js";
3
+ export { PipelineError, type PipelineErrorCode } from "./errors.js";
4
4
  export type { Task, Pipeline, TaskConfig, Command, CommandConfig, Commands, PipelineRunConfig, PipelineTaskConfig, RunResult, PipelineStats, TaskStats, TaskStatus, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { task } from "./task.js";
2
2
  export { pipeline } from "./pipeline.js";
3
- export { PipelineError } from "./pipeline-error.js";
3
+ export { PipelineError } from "./errors.js";
@@ -0,0 +1,2 @@
1
+ export declare const $TASK_INTERNAL: unique symbol;
2
+ export declare const $PIPELINE_INTERNAL: unique symbol;
@@ -0,0 +1,2 @@
1
+ export const $TASK_INTERNAL = Symbol("task-internal");
2
+ export const $PIPELINE_INTERNAL = Symbol("pipeline-internal");
@@ -0,0 +1,33 @@
1
+ import type { PipelineRunConfig, TaskStats } from "../types.js";
2
+ import type { TeardownManager } from "./teardown-manager.js";
3
+ import type { TimeoutManager } from "./timeout-manager.js";
4
+ import type { QueueManager } from "./queue-manager.js";
5
+ /**
6
+ * Represents a task execution in progress
7
+ */
8
+ export interface TaskExecution {
9
+ taskId: string;
10
+ taskName: string;
11
+ process?: import("node:child_process").ChildProcess;
12
+ startedAt: number;
13
+ readyAt?: number;
14
+ }
15
+ /**
16
+ * Centralized execution context that replaces scattered configuration parameters
17
+ * and provides unified access to execution state and helper methods
18
+ */
19
+ export interface ExecutionContext {
20
+ config: PipelineRunConfig;
21
+ signal?: AbortSignal;
22
+ spawn: typeof import("node:child_process").spawn;
23
+ teardownManager: TeardownManager;
24
+ timeoutManager: TimeoutManager;
25
+ queueManager: QueueManager;
26
+ taskStats: Map<string, TaskStats>;
27
+ updateTaskStatus: (taskId: string, updates: Partial<TaskStats>) => void;
28
+ isAborted: () => boolean;
29
+ }
30
+ /**
31
+ * Creates an execution context for pipeline execution
32
+ */
33
+ export declare function createExecutionContext(config: PipelineRunConfig, teardownManager: TeardownManager, timeoutManager: TimeoutManager, queueManager: QueueManager, taskStats: Map<string, TaskStats>): ExecutionContext;
@@ -0,0 +1,30 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Creates an execution context for pipeline execution
4
+ */
5
+ export function createExecutionContext(config, teardownManager, timeoutManager, queueManager, taskStats) {
6
+ // Use dynamic import only if spawn is not provided
7
+ const spawnFn = config.spawn ?? spawn;
8
+ return {
9
+ config,
10
+ signal: config.signal,
11
+ spawn: spawnFn,
12
+ teardownManager,
13
+ timeoutManager,
14
+ queueManager,
15
+ taskStats,
16
+ updateTaskStatus(taskId, updates) {
17
+ const currentStats = taskStats.get(taskId);
18
+ const updatedStats = { ...currentStats, ...updates };
19
+ // Calculate duration if both start and finish times are available
20
+ if (updatedStats.startedAt && updatedStats.finishedAt) {
21
+ updatedStats.durationMs =
22
+ updatedStats.finishedAt - updatedStats.startedAt;
23
+ }
24
+ taskStats.set(taskId, updatedStats);
25
+ },
26
+ isAborted() {
27
+ return config.signal?.aborted ?? false;
28
+ },
29
+ };
30
+ }
@@ -1,2 +1,2 @@
1
- import type { TaskGraph, Task } from "./types.js";
1
+ import type { TaskGraph, Task } from "../types.js";
2
2
  export declare function createTaskGraph(tasks: Task[]): TaskGraph;
@@ -0,0 +1,24 @@
1
+ import type { TaskGraph } from "../types.js";
2
+ import type { TaskExecution } from "./execution-context.js";
3
+ /**
4
+ * Queue manager interface returned by createQueueManager
5
+ */
6
+ export interface QueueManager {
7
+ getNextReadyTask(): string | null;
8
+ markRunningTaskReady(taskId: string): void;
9
+ markTaskComplete(taskId: string): void;
10
+ markTaskFailed(taskId: string): void;
11
+ markTaskSkipped(taskId: string): void;
12
+ markTaskRunning(taskId: string, execution: TaskExecution): void;
13
+ canExecuteMore(): boolean;
14
+ isComplete(): boolean;
15
+ hasFailed(): boolean;
16
+ getRunningTasks(): Map<string, TaskExecution>;
17
+ clearQueues(): void;
18
+ abortAllRunningTasks(): void;
19
+ }
20
+ /**
21
+ * Creates a queue manager that replaces the generator-based scheduler
22
+ * with explicit queue-based execution state management
23
+ */
24
+ export declare function createQueueManager(graph: TaskGraph, maxConcurrency?: number): QueueManager;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Creates a queue manager that replaces the generator-based scheduler
3
+ * with explicit queue-based execution state management
4
+ */
5
+ export function createQueueManager(graph, maxConcurrency) {
6
+ const readyQueue = [];
7
+ const waitingQueue = new Map();
8
+ const runningTasks = new Map();
9
+ const completedTasks = new Set();
10
+ const failedTasks = new Set();
11
+ const skippedTasks = new Set();
12
+ const maxConcurrencyLimit = maxConcurrency ?? Infinity;
13
+ let status = "running";
14
+ for (const [taskId, node] of graph.nodes) {
15
+ const depCount = node.dependencies.size;
16
+ if (depCount === 0) {
17
+ readyQueue.push(taskId);
18
+ }
19
+ else {
20
+ waitingQueue.set(taskId, depCount);
21
+ }
22
+ }
23
+ const updateDependentTasks = (completedTaskId) => {
24
+ const node = graph.nodes.get(completedTaskId);
25
+ if (!node)
26
+ return;
27
+ for (const dependentId of node.dependents) {
28
+ const currentCount = waitingQueue.get(dependentId);
29
+ if (currentCount === undefined)
30
+ continue;
31
+ const newCount = currentCount - 1;
32
+ if (newCount > 0) {
33
+ waitingQueue.set(dependentId, newCount);
34
+ continue;
35
+ }
36
+ waitingQueue.delete(dependentId);
37
+ readyQueue.push(dependentId);
38
+ }
39
+ };
40
+ const allTasksFinished = () => {
41
+ const totalTasks = graph.nodes.size;
42
+ const finishedTasks = completedTasks.size + failedTasks.size + skippedTasks.size;
43
+ return finishedTasks === totalTasks;
44
+ };
45
+ /**
46
+ * Update execution status based on current queue state
47
+ */
48
+ const updateExecutionStatus = () => {
49
+ if (status === "failed" || status === "aborted") {
50
+ return; // Don't change from terminal states
51
+ }
52
+ if (allTasksFinished()) {
53
+ status = "completed";
54
+ }
55
+ };
56
+ return {
57
+ /**
58
+ * Get the next ready task for execution, respecting concurrency limits
59
+ * Returns null if pipeline has failed or been aborted
60
+ */
61
+ getNextReadyTask() {
62
+ // Don't return tasks if pipeline has failed or been aborted
63
+ if (status === "failed" || status === "aborted") {
64
+ return null;
65
+ }
66
+ if (readyQueue.length === 0)
67
+ return null;
68
+ if (runningTasks.size >= maxConcurrencyLimit)
69
+ return null;
70
+ // Additional check: if we have failed tasks, don't process new ones
71
+ if (failedTasks.size > 0) {
72
+ return null;
73
+ }
74
+ const taskId = readyQueue.shift() ?? null;
75
+ if (taskId) {
76
+ // Final check before returning - prevent race conditions
77
+ // Check failed tasks to prevent race conditions (status check already done above)
78
+ if (failedTasks.size > 0) {
79
+ // Put it back if we detected failure
80
+ readyQueue.unshift(taskId);
81
+ return null;
82
+ }
83
+ }
84
+ return taskId;
85
+ },
86
+ /**
87
+ * Mark a running task as ready (via readyWhen) and update dependent tasks
88
+ */
89
+ markRunningTaskReady(taskId) {
90
+ if (runningTasks.has(taskId)) {
91
+ updateDependentTasks(taskId);
92
+ }
93
+ },
94
+ /**
95
+ * Mark a task as complete and update dependent tasks
96
+ */
97
+ markTaskComplete(taskId) {
98
+ runningTasks.delete(taskId);
99
+ completedTasks.add(taskId);
100
+ updateDependentTasks(taskId);
101
+ updateExecutionStatus();
102
+ },
103
+ /**
104
+ * Mark a task as failed
105
+ * When a task fails, we clear the ready queue immediately to prevent dependent tasks from starting.
106
+ * Note: This clears only the ready queue. The full cleanup (including waiting queue) happens
107
+ * in failPipeline via clearQueues(). This immediate ready queue clearing is critical to prevent
108
+ * race conditions where dependent tasks might be in the ready queue when a dependency fails.
109
+ * Failed tasks don't update dependents as they block the pipeline.
110
+ */
111
+ markTaskFailed(taskId) {
112
+ runningTasks.delete(taskId);
113
+ failedTasks.add(taskId);
114
+ status = "failed";
115
+ // Clear ready queue immediately to prevent any dependent tasks from starting
116
+ // This is critical to prevent race conditions where dependent tasks might
117
+ // be in the ready queue when a dependency fails
118
+ readyQueue.length = 0;
119
+ },
120
+ /**
121
+ * Mark a task as skipped and update dependent tasks
122
+ */
123
+ markTaskSkipped(taskId) {
124
+ runningTasks.delete(taskId);
125
+ skippedTasks.add(taskId);
126
+ updateDependentTasks(taskId);
127
+ updateExecutionStatus();
128
+ },
129
+ /**
130
+ * Mark a task as running
131
+ */
132
+ markTaskRunning(taskId, execution) {
133
+ runningTasks.set(taskId, execution);
134
+ },
135
+ /**
136
+ * Check if there are more tasks that can be executed
137
+ */
138
+ canExecuteMore() {
139
+ return readyQueue.length > 0 && runningTasks.size < maxConcurrencyLimit;
140
+ },
141
+ /**
142
+ * Check if all tasks are complete (either completed, failed, or skipped)
143
+ */
144
+ isComplete: allTasksFinished,
145
+ /**
146
+ * Check if any tasks have failed
147
+ */
148
+ hasFailed() {
149
+ return failedTasks.size > 0;
150
+ },
151
+ /**
152
+ * Get current running tasks
153
+ */
154
+ getRunningTasks() {
155
+ return new Map(runningTasks);
156
+ },
157
+ /**
158
+ * Clear all queues (used during cancellation)
159
+ * This prevents any pending tasks from starting, but preserves their status
160
+ * as "pending" in the task stats (they are not marked as aborted)
161
+ */
162
+ clearQueues() {
163
+ readyQueue.length = 0;
164
+ waitingQueue.clear();
165
+ // Keep running tasks for cleanup, but mark them for termination
166
+ // Note: Tasks in waitingQueue remain with "pending" status - they are not
167
+ // moved to failed/aborted state since they never started
168
+ },
169
+ /**
170
+ * Mark all running tasks as aborted and clear them from running tasks
171
+ * Used during cancellation to ensure proper state consistency
172
+ */
173
+ abortAllRunningTasks() {
174
+ // Move all running tasks to failed state
175
+ for (const taskId of runningTasks.keys()) {
176
+ failedTasks.add(taskId);
177
+ }
178
+ runningTasks.clear();
179
+ status = "aborted";
180
+ },
181
+ };
182
+ }
@@ -21,9 +21,12 @@ export function createSignalHandler({ abortSignal, onAborted, onProcessTerminate
21
21
  // Handle abort signal if provided
22
22
  let signalCleanup = null;
23
23
  if (abortSignal) {
24
- abortSignal.addEventListener("abort", onAborted);
24
+ const handleAbort = () => {
25
+ onAborted();
26
+ };
27
+ abortSignal.addEventListener("abort", handleAbort);
25
28
  signalCleanup = () => {
26
- abortSignal.removeEventListener("abort", onAborted);
29
+ abortSignal.removeEventListener("abort", handleAbort);
27
30
  };
28
31
  }
29
32
  return {
@@ -0,0 +1,33 @@
1
+ import type { Task } from "../types.js";
2
+ import type { ExecutionContext } from "./execution-context.js";
3
+ /**
4
+ * Callbacks for task execution events that replace scheduler coordination.
5
+ * These callbacks are invoked by the task executor to notify the queue manager
6
+ * of task state changes, enabling queue-based execution flow.
7
+ */
8
+ export interface TaskExecutionCallbacks {
9
+ /**
10
+ * Called when a task becomes ready (e.g., via readyWhen condition).
11
+ * This allows dependent tasks to start executing.
12
+ */
13
+ onTaskReady: (taskId: string) => void;
14
+ /**
15
+ * Called when a task completes successfully.
16
+ * Updates dependent task dependency counts and moves ready tasks to execution queue.
17
+ */
18
+ onTaskComplete: (taskId: string) => void;
19
+ /**
20
+ * Called when a task fails.
21
+ * Triggers pipeline failure and cleanup.
22
+ */
23
+ onTaskFailed: (taskId: string, error: Error) => void;
24
+ /**
25
+ * Called when a task is skipped (e.g., missing command in non-strict mode).
26
+ * Treated similarly to completion for dependency resolution.
27
+ */
28
+ onTaskSkipped: (taskId: string) => void;
29
+ }
30
+ /**
31
+ * Executes a task (either a regular task or a nested pipeline).
32
+ */
33
+ export declare function executeTask(task: Task, context: ExecutionContext, callbacks: TaskExecutionCallbacks): void;