@calmo/task-runner 3.3.0 → 3.4.1

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.
Files changed (95) hide show
  1. package/.github/dependabot.yml +7 -7
  2. package/.github/workflows/ci.yml +4 -4
  3. package/.jules/backlog_maniac.md +4 -0
  4. package/.jules/nexus.md +1 -0
  5. package/.jules/sentinel.md +1 -0
  6. package/.releaserc.json +2 -7
  7. package/AGENTS.md +8 -2
  8. package/CHANGELOG.md +181 -167
  9. package/README.md +23 -23
  10. package/coverage/coverage-final.json +8 -7
  11. package/coverage/index.html +7 -7
  12. package/coverage/lcov-report/index.html +7 -7
  13. package/coverage/lcov-report/src/EventBus.ts.html +28 -22
  14. package/coverage/lcov-report/src/TaskGraphValidationError.ts.html +130 -0
  15. package/coverage/lcov-report/src/TaskGraphValidator.ts.html +166 -151
  16. package/coverage/lcov-report/src/TaskRunner.ts.html +69 -54
  17. package/coverage/lcov-report/src/TaskRunnerBuilder.ts.html +29 -5
  18. package/coverage/lcov-report/src/TaskRunnerExecutionConfig.ts.html +1 -1
  19. package/coverage/lcov-report/src/TaskStateManager.ts.html +1 -1
  20. package/coverage/lcov-report/src/WorkflowExecutor.ts.html +21 -12
  21. package/coverage/lcov-report/src/contracts/RunnerEvents.ts.html +1 -1
  22. package/coverage/lcov-report/src/contracts/index.html +1 -1
  23. package/coverage/lcov-report/src/index.html +23 -8
  24. package/coverage/lcov-report/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
  25. package/coverage/lcov-report/src/strategies/RetryingExecutionStrategy.ts.html +30 -12
  26. package/coverage/lcov-report/src/strategies/StandardExecutionStrategy.ts.html +5 -5
  27. package/coverage/lcov-report/src/strategies/index.html +1 -1
  28. package/coverage/lcov.info +296 -278
  29. package/coverage/src/EventBus.ts.html +28 -22
  30. package/coverage/src/TaskGraphValidationError.ts.html +130 -0
  31. package/coverage/src/TaskGraphValidator.ts.html +166 -151
  32. package/coverage/src/TaskRunner.ts.html +69 -54
  33. package/coverage/src/TaskRunnerBuilder.ts.html +29 -5
  34. package/coverage/src/TaskRunnerExecutionConfig.ts.html +1 -1
  35. package/coverage/src/TaskStateManager.ts.html +1 -1
  36. package/coverage/src/WorkflowExecutor.ts.html +21 -12
  37. package/coverage/src/contracts/RunnerEvents.ts.html +1 -1
  38. package/coverage/src/contracts/index.html +1 -1
  39. package/coverage/src/index.html +23 -8
  40. package/coverage/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
  41. package/coverage/src/strategies/RetryingExecutionStrategy.ts.html +30 -12
  42. package/coverage/src/strategies/StandardExecutionStrategy.ts.html +5 -5
  43. package/coverage/src/strategies/index.html +1 -1
  44. package/dist/EventBus.js +13 -11
  45. package/dist/EventBus.js.map +1 -1
  46. package/dist/TaskGraphValidationError.d.ts +9 -0
  47. package/dist/TaskGraphValidationError.js +13 -0
  48. package/dist/TaskGraphValidationError.js.map +1 -0
  49. package/dist/TaskGraphValidator.js +9 -9
  50. package/dist/TaskGraphValidator.js.map +1 -1
  51. package/dist/TaskRunner.js +2 -1
  52. package/dist/TaskRunner.js.map +1 -1
  53. package/dist/TaskRunnerBuilder.js.map +1 -1
  54. package/dist/WorkflowExecutor.js +2 -1
  55. package/dist/WorkflowExecutor.js.map +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +1 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/strategies/RetryingExecutionStrategy.js +3 -1
  60. package/dist/strategies/RetryingExecutionStrategy.js.map +1 -1
  61. package/dist/strategies/StandardExecutionStrategy.js +1 -1
  62. package/dist/strategies/StandardExecutionStrategy.js.map +1 -1
  63. package/openspec/AGENTS.md +81 -15
  64. package/openspec/changes/{add-concurrency-control → archive/2026-01-18-add-concurrency-control}/proposal.md +7 -4
  65. package/openspec/changes/archive/2026-01-18-add-concurrency-control/tasks.md +10 -0
  66. package/openspec/changes/archive/2026-01-18-add-external-task-cancellation/proposal.md +4 -1
  67. package/openspec/changes/archive/2026-01-18-add-external-task-cancellation/tasks.md +2 -1
  68. package/openspec/changes/archive/2026-01-18-add-integration-tests/proposal.md +3 -0
  69. package/openspec/changes/archive/2026-01-18-add-integration-tests/tasks.md +1 -0
  70. package/openspec/changes/archive/2026-01-18-add-task-retry-policy/proposal.md +3 -0
  71. package/openspec/changes/archive/2026-01-18-add-task-retry-policy/tasks.md +1 -0
  72. package/openspec/changes/archive/2026-01-18-add-workflow-preview/proposal.md +3 -0
  73. package/openspec/changes/archive/2026-01-18-add-workflow-preview/tasks.md +1 -0
  74. package/openspec/changes/archive/2026-01-18-refactor-core-architecture/proposal.md +3 -0
  75. package/openspec/changes/archive/2026-01-18-refactor-core-architecture/tasks.md +1 -0
  76. package/openspec/changes/feat-per-task-timeout/proposal.md +11 -6
  77. package/openspec/changes/feat-per-task-timeout/tasks.md +1 -1
  78. package/openspec/project.md +21 -15
  79. package/package.json +1 -1
  80. package/src/EventBus.ts +18 -16
  81. package/src/TaskGraph.ts +8 -8
  82. package/src/TaskGraphValidationError.ts +15 -0
  83. package/src/TaskGraphValidator.ts +148 -143
  84. package/src/TaskRunner.ts +47 -42
  85. package/src/TaskRunnerBuilder.ts +11 -3
  86. package/src/WorkflowExecutor.ts +13 -10
  87. package/src/contracts/ITaskGraphValidator.ts +12 -12
  88. package/src/contracts/ValidationError.ts +6 -6
  89. package/src/contracts/ValidationResult.ts +4 -4
  90. package/src/index.ts +1 -0
  91. package/src/strategies/DryRunExecutionStrategy.ts +3 -3
  92. package/src/strategies/RetryingExecutionStrategy.ts +15 -9
  93. package/src/strategies/StandardExecutionStrategy.ts +4 -4
  94. package/test-report.xml +109 -107
  95. package/openspec/changes/add-concurrency-control/tasks.md +0 -9
package/src/EventBus.ts CHANGED
@@ -61,23 +61,25 @@ export class EventBus<TContext> {
61
61
  | undefined;
62
62
  if (listeners) {
63
63
  for (const listener of listeners) {
64
- try {
65
- const result = listener(data);
66
- if (result instanceof Promise) {
67
- result.catch((error) => {
68
- console.error(
69
- `Error in event listener for ${String(event)}:`,
70
- error
71
- );
72
- });
64
+ Promise.resolve().then(() => {
65
+ try {
66
+ const result = listener(data);
67
+ if (result instanceof Promise) {
68
+ result.catch((error) => {
69
+ console.error(
70
+ `Error in event listener for ${String(event)}:`,
71
+ error
72
+ );
73
+ });
74
+ }
75
+ } catch (error) {
76
+ // Prevent listener errors from bubbling up
77
+ console.error(
78
+ `Error in event listener for ${String(event)}:`,
79
+ error
80
+ );
73
81
  }
74
- } catch (error) {
75
- // Prevent listener errors from bubbling up
76
- console.error(
77
- `Error in event listener for ${String(event)}:`,
78
- error
79
- );
80
- }
82
+ });
81
83
  }
82
84
  }
83
85
  }
package/src/TaskGraph.ts CHANGED
@@ -2,18 +2,18 @@
2
2
  * Represents a single task in the task graph.
3
3
  */
4
4
  export interface Task {
5
- /** Unique identifier for the task. */
6
- id: string;
7
- /** An array of task IDs that this task directly depends on. */
8
- dependencies: string[];
9
- /** Allows for any other properties specific to the task's payload or configuration. */
10
- [key: string]: unknown;
5
+ /** Unique identifier for the task. */
6
+ id: string;
7
+ /** An array of task IDs that this task directly depends on. */
8
+ dependencies: string[];
9
+ /** Allows for any other properties specific to the task's payload or configuration. */
10
+ [key: string]: unknown;
11
11
  }
12
12
 
13
13
  /**
14
14
  * Represents the entire collection of tasks and their interdependencies.
15
15
  */
16
16
  export interface TaskGraph {
17
- /** An array of tasks that make up the graph. */
18
- tasks: Task[];
17
+ /** An array of tasks that make up the graph. */
18
+ tasks: Task[];
19
19
  }
@@ -0,0 +1,15 @@
1
+ import { ValidationResult } from "./contracts/ValidationResult.js";
2
+
3
+ /**
4
+ * Error thrown when a task graph fails validation.
5
+ * Contains the validation result with detailed error information.
6
+ */
7
+ export class TaskGraphValidationError extends Error {
8
+ constructor(
9
+ public result: ValidationResult,
10
+ message: string
11
+ ) {
12
+ super(message);
13
+ this.name = "TaskGraphValidationError";
14
+ }
15
+ }
@@ -4,160 +4,165 @@ import { ValidationError } from "./contracts/ValidationError.js";
4
4
  import { TaskGraph } from "./TaskGraph.js";
5
5
 
6
6
  export class TaskGraphValidator implements ITaskGraphValidator {
7
- /**
8
- * Validates a given task graph for structural integrity.
9
- * Checks for:
10
- * 1. Duplicate task IDs.
11
- * 2. Missing dependencies (tasks that depend on non-existent IDs).
12
- * 3. Circular dependencies (cycles in the graph).
13
- *
14
- * @param taskGraph The task graph to validate.
15
- * @returns A ValidationResult object indicating the outcome of the validation.
16
- */
17
- validate(taskGraph: TaskGraph): ValidationResult {
18
- const errors: ValidationError[] = [];
19
-
20
- // 1. Check for duplicate tasks
21
- const taskIds = new Set<string>();
22
- for (const task of taskGraph.tasks) {
23
- if (taskIds.has(task.id)) {
24
- errors.push({
25
- type: "duplicate_task",
26
- message: `Duplicate task detected with ID: ${task.id}`,
27
- details: { taskId: task.id }
28
- });
29
- } else {
30
- taskIds.add(task.id);
31
- }
32
- }
7
+ /**
8
+ * Validates a given task graph for structural integrity.
9
+ * Checks for:
10
+ * 1. Duplicate task IDs.
11
+ * 2. Missing dependencies (tasks that depend on non-existent IDs).
12
+ * 3. Circular dependencies (cycles in the graph).
13
+ *
14
+ * @param taskGraph The task graph to validate.
15
+ * @returns A ValidationResult object indicating the outcome of the validation.
16
+ */
17
+ validate(taskGraph: TaskGraph): ValidationResult {
18
+ const errors: ValidationError[] = [];
19
+
20
+ // 1. Check for duplicate tasks
21
+ const taskIds = new Set<string>();
22
+ for (const task of taskGraph.tasks) {
23
+ if (taskIds.has(task.id)) {
24
+ errors.push({
25
+ type: "duplicate_task",
26
+ message: `Duplicate task detected with ID: ${task.id}`,
27
+ details: { taskId: task.id },
28
+ });
29
+ } else {
30
+ taskIds.add(task.id);
31
+ }
32
+ }
33
33
 
34
- // 2. Check for missing dependencies
35
- for (const task of taskGraph.tasks) {
36
- for (const dependenceId of task.dependencies) {
37
- if (!taskIds.has(dependenceId)) {
38
- errors.push({
39
- type: "missing_dependency",
40
- message: `Task '${task.id}' depends on missing task '${dependenceId}'`,
41
- details: { taskId: task.id, missingDependencyId: dependenceId }
42
- });
43
- }
44
- }
34
+ // 2. Check for missing dependencies
35
+ for (const task of taskGraph.tasks) {
36
+ for (const dependenceId of task.dependencies) {
37
+ if (!taskIds.has(dependenceId)) {
38
+ errors.push({
39
+ type: "missing_dependency",
40
+ message: `Task '${task.id}' depends on missing task '${dependenceId}'`,
41
+ details: { taskId: task.id, missingDependencyId: dependenceId },
42
+ });
45
43
  }
44
+ }
45
+ }
46
46
 
47
- // 3. Check for cycles
48
- // Only run cycle detection if there are no missing dependencies, otherwise we might chase non-existent nodes.
49
- const hasMissingDependencies = errors.some(e => e.type === "missing_dependency");
47
+ // 3. Check for cycles
48
+ // Only run cycle detection if there are no missing dependencies, otherwise we might chase non-existent nodes.
49
+ const hasMissingDependencies = errors.some(
50
+ (e) => e.type === "missing_dependency"
51
+ );
52
+
53
+ if (hasMissingDependencies) {
54
+ return {
55
+ isValid: errors.length === 0,
56
+ errors,
57
+ };
58
+ }
50
59
 
51
- if (hasMissingDependencies) {
52
- return {
53
- isValid: errors.length === 0,
54
- errors
55
- };
56
- }
60
+ // Build adjacency list
61
+ const adjacencyList = new Map<string, string[]>();
62
+ for (const task of taskGraph.tasks) {
63
+ adjacencyList.set(task.id, task.dependencies);
64
+ }
57
65
 
58
- // Build adjacency list
59
- const adjacencyList = new Map<string, string[]>();
60
- for (const task of taskGraph.tasks) {
61
- adjacencyList.set(task.id, task.dependencies);
62
- }
66
+ const visited = new Set<string>();
67
+ const recursionStack = new Set<string>();
68
+
69
+ for (const task of taskGraph.tasks) {
70
+ if (visited.has(task.id)) {
71
+ continue;
72
+ }
73
+
74
+ const path: string[] = [];
75
+ if (
76
+ this.detectCycle(task.id, path, visited, recursionStack, adjacencyList)
77
+ ) {
78
+ // Extract the actual cycle from the path
79
+ // The path might look like A -> B -> C -> B (if we started at A and found cycle B-C-B)
80
+ const cycleStart = path[path.length - 1];
81
+ const cycleStartIndex = path.indexOf(cycleStart);
82
+ const cyclePath = path.slice(cycleStartIndex);
83
+
84
+ errors.push({
85
+ type: "cycle",
86
+ message: `Cycle detected: ${cyclePath.join(" -> ")}`,
87
+ details: { cyclePath },
88
+ });
89
+ // Break after first cycle found to avoid spamming similar errors
90
+ break;
91
+ }
92
+ }
63
93
 
64
- const visited = new Set<string>();
65
- const recursionStack = new Set<string>();
66
-
67
- for (const task of taskGraph.tasks) {
68
- if (visited.has(task.id)) {
69
- continue;
70
- }
71
-
72
- const path: string[] = [];
73
- if (this.detectCycle(task.id, path, visited, recursionStack, adjacencyList)) {
74
- // Extract the actual cycle from the path
75
- // The path might look like A -> B -> C -> B (if we started at A and found cycle B-C-B)
76
- const cycleStart = path[path.length - 1];
77
- const cycleStartIndex = path.indexOf(cycleStart);
78
- const cyclePath = path.slice(cycleStartIndex);
79
-
80
- errors.push({
81
- type: "cycle",
82
- message: `Cycle detected: ${cyclePath.join(" -> ")}`,
83
- details: { cyclePath }
84
- });
85
- // Break after first cycle found to avoid spamming similar errors
86
- break;
87
- }
94
+ return {
95
+ isValid: errors.length === 0,
96
+ errors,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Creates a human-readable error message from a validation result.
102
+ * @param result The validation result containing errors.
103
+ * @returns A formatted error string.
104
+ */
105
+ createErrorMessage(result: ValidationResult): string {
106
+ const errorDetails = result.errors.map((e) => e.message);
107
+ return `Task graph validation failed: ${errorDetails.join("; ")}`;
108
+ }
109
+
110
+ private detectCycle(
111
+ startTaskId: string,
112
+ path: string[],
113
+ visited: Set<string>,
114
+ recursionStack: Set<string>,
115
+ adjacencyList: Map<string, string[]>
116
+ ): boolean {
117
+ // Use an explicit stack to avoid maximum call stack size exceeded errors
118
+ const stack: { taskId: string; index: number; dependencies: string[] }[] =
119
+ [];
120
+
121
+ visited.add(startTaskId);
122
+ recursionStack.add(startTaskId);
123
+ path.push(startTaskId);
124
+
125
+ stack.push({
126
+ taskId: startTaskId,
127
+ index: 0,
128
+ /* v8 ignore next */
129
+ dependencies: adjacencyList.get(startTaskId) ?? [],
130
+ });
131
+
132
+ while (stack.length > 0) {
133
+ const frame = stack[stack.length - 1];
134
+ const { taskId, dependencies } = frame;
135
+
136
+ if (frame.index < dependencies.length) {
137
+ const dependenceId = dependencies[frame.index];
138
+ frame.index++;
139
+
140
+ if (recursionStack.has(dependenceId)) {
141
+ // Cycle detected
142
+ path.push(dependenceId);
143
+ return true;
88
144
  }
89
145
 
90
- return {
91
- isValid: errors.length === 0,
92
- errors
93
- };
94
- }
95
-
96
- /**
97
- * Creates a human-readable error message from a validation result.
98
- * @param result The validation result containing errors.
99
- * @returns A formatted error string.
100
- */
101
- createErrorMessage(result: ValidationResult): string {
102
- const errorDetails = result.errors.map(e => e.message);
103
- return `Task graph validation failed: ${errorDetails.join("; ")}`;
104
- }
146
+ if (!visited.has(dependenceId)) {
147
+ visited.add(dependenceId);
148
+ recursionStack.add(dependenceId);
149
+ path.push(dependenceId);
105
150
 
106
- private detectCycle(
107
- startTaskId: string,
108
- path: string[],
109
- visited: Set<string>,
110
- recursionStack: Set<string>,
111
- adjacencyList: Map<string, string[]>
112
- ): boolean {
113
- // Use an explicit stack to avoid maximum call stack size exceeded errors
114
- const stack: { taskId: string; index: number; dependencies: string[] }[] = [];
115
-
116
- visited.add(startTaskId);
117
- recursionStack.add(startTaskId);
118
- path.push(startTaskId);
119
-
120
- stack.push({
121
- taskId: startTaskId,
151
+ stack.push({
152
+ taskId: dependenceId,
122
153
  index: 0,
123
154
  /* v8 ignore next */
124
- dependencies: adjacencyList.get(startTaskId) ?? []
125
- });
126
-
127
- while (stack.length > 0) {
128
- const frame = stack[stack.length - 1];
129
- const { taskId, dependencies } = frame;
130
-
131
- if (frame.index < dependencies.length) {
132
- const dependenceId = dependencies[frame.index];
133
- frame.index++;
134
-
135
- if (recursionStack.has(dependenceId)) {
136
- // Cycle detected
137
- path.push(dependenceId);
138
- return true;
139
- }
140
-
141
- if (!visited.has(dependenceId)) {
142
- visited.add(dependenceId);
143
- recursionStack.add(dependenceId);
144
- path.push(dependenceId);
145
-
146
- stack.push({
147
- taskId: dependenceId,
148
- index: 0,
149
- /* v8 ignore next */
150
- dependencies: adjacencyList.get(dependenceId) ?? []
151
- });
152
- }
153
- } else {
154
- // Finished all dependencies for this node
155
- recursionStack.delete(taskId);
156
- path.pop();
157
- stack.pop();
158
- }
155
+ dependencies: adjacencyList.get(dependenceId) ?? [],
156
+ });
159
157
  }
160
-
161
- return false;
158
+ } else {
159
+ // Finished all dependencies for this node
160
+ recursionStack.delete(taskId);
161
+ path.pop();
162
+ stack.pop();
163
+ }
162
164
  }
165
+
166
+ return false;
167
+ }
163
168
  }
package/src/TaskRunner.ts CHANGED
@@ -2,11 +2,15 @@ import { TaskStep } from "./TaskStep.js";
2
2
  import { TaskResult } from "./TaskResult.js";
3
3
  import { TaskGraphValidator } from "./TaskGraphValidator.js";
4
4
  import { TaskGraph } from "./TaskGraph.js";
5
- import { RunnerEventPayloads, RunnerEventListener } from "./contracts/RunnerEvents.js";
5
+ import {
6
+ RunnerEventPayloads,
7
+ RunnerEventListener,
8
+ } from "./contracts/RunnerEvents.js";
6
9
  import { EventBus } from "./EventBus.js";
7
10
  import { WorkflowExecutor } from "./WorkflowExecutor.js";
8
11
  import { TaskRunnerExecutionConfig } from "./TaskRunnerExecutionConfig.js";
9
12
  import { TaskStateManager } from "./TaskStateManager.js";
13
+ import { TaskGraphValidationError } from "./TaskGraphValidationError.js";
10
14
  import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
11
15
  import { StandardExecutionStrategy } from "./strategies/StandardExecutionStrategy.js";
12
16
  import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrategy.js";
@@ -23,9 +27,8 @@ export { RunnerEventPayloads, RunnerEventListener, TaskRunnerExecutionConfig };
23
27
  export class TaskRunner<TContext> {
24
28
  private eventBus = new EventBus<TContext>();
25
29
  private validator = new TaskGraphValidator();
26
- private executionStrategy: IExecutionStrategy<TContext> = new RetryingExecutionStrategy(
27
- new StandardExecutionStrategy()
28
- );
30
+ private executionStrategy: IExecutionStrategy<TContext> =
31
+ new RetryingExecutionStrategy(new StandardExecutionStrategy());
29
32
 
30
33
  /**
31
34
  * @param context The shared context object to be passed to each task.
@@ -84,7 +87,6 @@ export class TaskRunner<TContext> {
84
87
  const safeId = (name: string) => JSON.stringify(name);
85
88
  const sanitize = (name: string) => this.sanitizeMermaidId(name);
86
89
 
87
-
88
90
  // Add all nodes first to ensure they exist
89
91
  for (const step of steps) {
90
92
  // Using the name as both ID and Label for simplicity
@@ -97,9 +99,7 @@ export class TaskRunner<TContext> {
97
99
  for (const step of steps) {
98
100
  if (step.dependencies) {
99
101
  for (const dep of step.dependencies) {
100
- graphLines.push(
101
- ` ${sanitize(dep)} --> ${sanitize(step.name)}`
102
- );
102
+ graphLines.push(` ${sanitize(dep)} --> ${sanitize(step.name)}`);
103
103
  }
104
104
  }
105
105
  }
@@ -138,7 +138,10 @@ export class TaskRunner<TContext> {
138
138
 
139
139
  const validationResult = this.validator.validate(taskGraph);
140
140
  if (!validationResult.isValid) {
141
- throw new Error(this.validator.createErrorMessage(validationResult));
141
+ throw new TaskGraphValidationError(
142
+ validationResult,
143
+ this.validator.createErrorMessage(validationResult)
144
+ );
142
145
  }
143
146
 
144
147
  const stateManager = new TaskStateManager(this.eventBus);
@@ -158,40 +161,42 @@ export class TaskRunner<TContext> {
158
161
 
159
162
  // We need to handle the timeout cleanup properly.
160
163
  if (config?.timeout !== undefined) {
161
- const controller = new AbortController();
162
- const timeoutId = setTimeout(() => {
163
- controller.abort(new Error(`Workflow timed out after ${config.timeout}ms`));
164
- }, config.timeout);
165
-
166
- let effectiveSignal = controller.signal;
167
- let onAbort: (() => void) | undefined;
168
-
169
- // Handle combination of signals if user provided one
170
- if (config.signal) {
171
- if (config.signal.aborted) {
172
- // If already aborted, use it directly (WorkflowExecutor handles early abort)
173
- // We can cancel timeout immediately
174
- clearTimeout(timeoutId);
175
- effectiveSignal = config.signal;
176
- } else {
177
- // Listen to user signal to abort our controller
178
- onAbort = () => {
179
- controller.abort(config.signal?.reason);
180
- };
181
- config.signal.addEventListener("abort", onAbort);
182
- }
183
- }
184
-
185
- try {
186
- return await executor.execute(steps, effectiveSignal);
187
- } finally {
188
- clearTimeout(timeoutId);
189
- if (config.signal && onAbort) {
190
- config.signal.removeEventListener("abort", onAbort);
191
- }
192
- }
164
+ const controller = new AbortController();
165
+ const timeoutId = setTimeout(() => {
166
+ controller.abort(
167
+ new Error(`Workflow timed out after ${config.timeout}ms`)
168
+ );
169
+ }, config.timeout);
170
+
171
+ let effectiveSignal = controller.signal;
172
+ let onAbort: (() => void) | undefined;
173
+
174
+ // Handle combination of signals if user provided one
175
+ if (config.signal) {
176
+ if (config.signal.aborted) {
177
+ // If already aborted, use it directly (WorkflowExecutor handles early abort)
178
+ // We can cancel timeout immediately
179
+ clearTimeout(timeoutId);
180
+ effectiveSignal = config.signal;
181
+ } else {
182
+ // Listen to user signal to abort our controller
183
+ onAbort = () => {
184
+ controller.abort(config.signal?.reason);
185
+ };
186
+ config.signal.addEventListener("abort", onAbort);
187
+ }
188
+ }
189
+
190
+ try {
191
+ return await executor.execute(steps, effectiveSignal);
192
+ } finally {
193
+ clearTimeout(timeoutId);
194
+ if (config.signal && onAbort) {
195
+ config.signal.removeEventListener("abort", onAbort);
196
+ }
197
+ }
193
198
  } else {
194
- return executor.execute(steps, config?.signal);
199
+ return executor.execute(steps, config?.signal);
195
200
  }
196
201
  }
197
202
  }
@@ -1,5 +1,8 @@
1
1
  import { TaskRunner } from "./TaskRunner.js";
2
- import { RunnerEventPayloads, RunnerEventListener } from "./contracts/RunnerEvents.js";
2
+ import {
3
+ RunnerEventPayloads,
4
+ RunnerEventListener,
5
+ } from "./contracts/RunnerEvents.js";
3
6
  import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
4
7
 
5
8
  /**
@@ -9,7 +12,10 @@ export class TaskRunnerBuilder<TContext> {
9
12
  private context: TContext;
10
13
  private strategy?: IExecutionStrategy<TContext>;
11
14
  private listeners: {
12
- [K in keyof RunnerEventPayloads<TContext>]?: RunnerEventListener<TContext, K>[];
15
+ [K in keyof RunnerEventPayloads<TContext>]?: RunnerEventListener<
16
+ TContext,
17
+ K
18
+ >[];
13
19
  } = {};
14
20
 
15
21
  /**
@@ -57,7 +63,9 @@ export class TaskRunnerBuilder<TContext> {
57
63
  runner.setExecutionStrategy(this.strategy);
58
64
  }
59
65
 
60
- (Object.keys(this.listeners) as Array<keyof RunnerEventPayloads<TContext>>).forEach((event) => {
66
+ (
67
+ Object.keys(this.listeners) as Array<keyof RunnerEventPayloads<TContext>>
68
+ ).forEach((event) => {
61
69
  const callbacks = this.listeners[event];
62
70
  // callbacks is always defined because we are iterating keys of the object
63
71
  callbacks!.forEach((callback) =>
@@ -41,7 +41,9 @@ export class WorkflowExecutor<TContext> {
41
41
 
42
42
  // Check if already aborted
43
43
  if (signal?.aborted) {
44
- this.stateManager.cancelAllPending("Workflow cancelled before execution started.");
44
+ this.stateManager.cancelAllPending(
45
+ "Workflow cancelled before execution started."
46
+ );
45
47
  const results = this.stateManager.getResults();
46
48
  this.eventBus.emit("workflowEnd", { context: this.context, results });
47
49
  return results;
@@ -55,7 +57,7 @@ export class WorkflowExecutor<TContext> {
55
57
  };
56
58
 
57
59
  if (signal) {
58
- signal.addEventListener("abort", onAbort);
60
+ signal.addEventListener("abort", onAbort);
59
61
  }
60
62
 
61
63
  try {
@@ -77,10 +79,10 @@ export class WorkflowExecutor<TContext> {
77
79
  }
78
80
 
79
81
  if (signal?.aborted) {
80
- this.stateManager.cancelAllPending("Workflow cancelled.");
82
+ this.stateManager.cancelAllPending("Workflow cancelled.");
81
83
  } else {
82
- // After a task finishes, check for new work
83
- this.processLoop(executingPromises, signal);
84
+ // After a task finishes, check for new work
85
+ this.processLoop(executingPromises, signal);
84
86
  }
85
87
  }
86
88
 
@@ -124,14 +126,15 @@ export class WorkflowExecutor<TContext> {
124
126
 
125
127
  this.stateManager.markRunning(step);
126
128
 
127
- const taskPromise = this.strategy.execute(step, this.context, signal)
129
+ const taskPromise = this.strategy
130
+ .execute(step, this.context, signal)
128
131
  .then((result) => {
129
- this.stateManager.markCompleted(step, result);
132
+ this.stateManager.markCompleted(step, result);
130
133
  })
131
134
  .finally(() => {
132
- executingPromises.delete(taskPromise);
133
- // When a task finishes, we try to run more
134
- this.processLoop(executingPromises, signal);
135
+ executingPromises.delete(taskPromise);
136
+ // When a task finishes, we try to run more
137
+ this.processLoop(executingPromises, signal);
135
138
  });
136
139
 
137
140
  executingPromises.add(taskPromise);
@@ -5,17 +5,17 @@ import { ValidationResult } from "./ValidationResult.js";
5
5
  * Defines the interface for a task graph validator.
6
6
  */
7
7
  export interface ITaskGraphValidator {
8
- /**
9
- * Validates a given task graph for structural integrity.
10
- * @param taskGraph The task graph to validate.
11
- * @returns A ValidationResult object indicating the outcome of the validation.
12
- */
13
- validate(taskGraph: TaskGraph): ValidationResult;
8
+ /**
9
+ * Validates a given task graph for structural integrity.
10
+ * @param taskGraph The task graph to validate.
11
+ * @returns A ValidationResult object indicating the outcome of the validation.
12
+ */
13
+ validate(taskGraph: TaskGraph): ValidationResult;
14
14
 
15
- /**
16
- * Creates a human-readable error message from a validation result.
17
- * @param result The validation result containing errors.
18
- * @returns A formatted error string.
19
- */
20
- createErrorMessage(result: ValidationResult): string;
15
+ /**
16
+ * Creates a human-readable error message from a validation result.
17
+ * @param result The validation result containing errors.
18
+ * @returns A formatted error string.
19
+ */
20
+ createErrorMessage(result: ValidationResult): string;
21
21
  }
@@ -2,10 +2,10 @@
2
2
  * Describes a specific validation error found in the task graph.
3
3
  */
4
4
  export interface ValidationError {
5
- /** The type of validation error. */
6
- type: "cycle" | "missing_dependency" | "duplicate_task";
7
- /** A human-readable message describing the error. */
8
- message: string;
9
- /** Optional detailed information about the error, e.g., the cycle path, or the task with a missing dependency. */
10
- details?: unknown;
5
+ /** The type of validation error. */
6
+ type: "cycle" | "missing_dependency" | "duplicate_task";
7
+ /** A human-readable message describing the error. */
8
+ message: string;
9
+ /** Optional detailed information about the error, e.g., the cycle path, or the task with a missing dependency. */
10
+ details?: unknown;
11
11
  }