@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.
- package/.github/dependabot.yml +7 -7
- package/.github/workflows/ci.yml +4 -4
- package/.jules/backlog_maniac.md +4 -0
- package/.jules/nexus.md +1 -0
- package/.jules/sentinel.md +1 -0
- package/.releaserc.json +2 -7
- package/AGENTS.md +8 -2
- package/CHANGELOG.md +181 -167
- package/README.md +23 -23
- package/coverage/coverage-final.json +8 -7
- package/coverage/index.html +7 -7
- package/coverage/lcov-report/index.html +7 -7
- package/coverage/lcov-report/src/EventBus.ts.html +28 -22
- package/coverage/lcov-report/src/TaskGraphValidationError.ts.html +130 -0
- package/coverage/lcov-report/src/TaskGraphValidator.ts.html +166 -151
- package/coverage/lcov-report/src/TaskRunner.ts.html +69 -54
- package/coverage/lcov-report/src/TaskRunnerBuilder.ts.html +29 -5
- package/coverage/lcov-report/src/TaskRunnerExecutionConfig.ts.html +1 -1
- package/coverage/lcov-report/src/TaskStateManager.ts.html +1 -1
- package/coverage/lcov-report/src/WorkflowExecutor.ts.html +21 -12
- package/coverage/lcov-report/src/contracts/RunnerEvents.ts.html +1 -1
- package/coverage/lcov-report/src/contracts/index.html +1 -1
- package/coverage/lcov-report/src/index.html +23 -8
- package/coverage/lcov-report/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
- package/coverage/lcov-report/src/strategies/RetryingExecutionStrategy.ts.html +30 -12
- package/coverage/lcov-report/src/strategies/StandardExecutionStrategy.ts.html +5 -5
- package/coverage/lcov-report/src/strategies/index.html +1 -1
- package/coverage/lcov.info +296 -278
- package/coverage/src/EventBus.ts.html +28 -22
- package/coverage/src/TaskGraphValidationError.ts.html +130 -0
- package/coverage/src/TaskGraphValidator.ts.html +166 -151
- package/coverage/src/TaskRunner.ts.html +69 -54
- package/coverage/src/TaskRunnerBuilder.ts.html +29 -5
- package/coverage/src/TaskRunnerExecutionConfig.ts.html +1 -1
- package/coverage/src/TaskStateManager.ts.html +1 -1
- package/coverage/src/WorkflowExecutor.ts.html +21 -12
- package/coverage/src/contracts/RunnerEvents.ts.html +1 -1
- package/coverage/src/contracts/index.html +1 -1
- package/coverage/src/index.html +23 -8
- package/coverage/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
- package/coverage/src/strategies/RetryingExecutionStrategy.ts.html +30 -12
- package/coverage/src/strategies/StandardExecutionStrategy.ts.html +5 -5
- package/coverage/src/strategies/index.html +1 -1
- package/dist/EventBus.js +13 -11
- package/dist/EventBus.js.map +1 -1
- package/dist/TaskGraphValidationError.d.ts +9 -0
- package/dist/TaskGraphValidationError.js +13 -0
- package/dist/TaskGraphValidationError.js.map +1 -0
- package/dist/TaskGraphValidator.js +9 -9
- package/dist/TaskGraphValidator.js.map +1 -1
- package/dist/TaskRunner.js +2 -1
- package/dist/TaskRunner.js.map +1 -1
- package/dist/TaskRunnerBuilder.js.map +1 -1
- package/dist/WorkflowExecutor.js +2 -1
- package/dist/WorkflowExecutor.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/strategies/RetryingExecutionStrategy.js +3 -1
- package/dist/strategies/RetryingExecutionStrategy.js.map +1 -1
- package/dist/strategies/StandardExecutionStrategy.js +1 -1
- package/dist/strategies/StandardExecutionStrategy.js.map +1 -1
- package/openspec/AGENTS.md +81 -15
- package/openspec/changes/{add-concurrency-control → archive/2026-01-18-add-concurrency-control}/proposal.md +7 -4
- package/openspec/changes/archive/2026-01-18-add-concurrency-control/tasks.md +10 -0
- package/openspec/changes/archive/2026-01-18-add-external-task-cancellation/proposal.md +4 -1
- package/openspec/changes/archive/2026-01-18-add-external-task-cancellation/tasks.md +2 -1
- package/openspec/changes/archive/2026-01-18-add-integration-tests/proposal.md +3 -0
- package/openspec/changes/archive/2026-01-18-add-integration-tests/tasks.md +1 -0
- package/openspec/changes/archive/2026-01-18-add-task-retry-policy/proposal.md +3 -0
- package/openspec/changes/archive/2026-01-18-add-task-retry-policy/tasks.md +1 -0
- package/openspec/changes/archive/2026-01-18-add-workflow-preview/proposal.md +3 -0
- package/openspec/changes/archive/2026-01-18-add-workflow-preview/tasks.md +1 -0
- package/openspec/changes/archive/2026-01-18-refactor-core-architecture/proposal.md +3 -0
- package/openspec/changes/archive/2026-01-18-refactor-core-architecture/tasks.md +1 -0
- package/openspec/changes/feat-per-task-timeout/proposal.md +11 -6
- package/openspec/changes/feat-per-task-timeout/tasks.md +1 -1
- package/openspec/project.md +21 -15
- package/package.json +1 -1
- package/src/EventBus.ts +18 -16
- package/src/TaskGraph.ts +8 -8
- package/src/TaskGraphValidationError.ts +15 -0
- package/src/TaskGraphValidator.ts +148 -143
- package/src/TaskRunner.ts +47 -42
- package/src/TaskRunnerBuilder.ts +11 -3
- package/src/WorkflowExecutor.ts +13 -10
- package/src/contracts/ITaskGraphValidator.ts +12 -12
- package/src/contracts/ValidationError.ts +6 -6
- package/src/contracts/ValidationResult.ts +4 -4
- package/src/index.ts +1 -0
- package/src/strategies/DryRunExecutionStrategy.ts +3 -3
- package/src/strategies/RetryingExecutionStrategy.ts +15 -9
- package/src/strategies/StandardExecutionStrategy.ts +4 -4
- package/test-report.xml +109 -107
- 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
result
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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> =
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
199
|
+
return executor.execute(steps, config?.signal);
|
|
195
200
|
}
|
|
196
201
|
}
|
|
197
202
|
}
|
package/src/TaskRunnerBuilder.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { TaskRunner } from "./TaskRunner.js";
|
|
2
|
-
import {
|
|
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<
|
|
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
|
-
(
|
|
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) =>
|
package/src/WorkflowExecutor.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
this.stateManager.cancelAllPending("Workflow cancelled.");
|
|
81
83
|
} else {
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
129
|
+
const taskPromise = this.strategy
|
|
130
|
+
.execute(step, this.context, signal)
|
|
128
131
|
.then((result) => {
|
|
129
|
-
|
|
132
|
+
this.stateManager.markCompleted(step, result);
|
|
130
133
|
})
|
|
131
134
|
.finally(() => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|