@calmo/task-runner 3.4.0 → 3.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/.github/dependabot.yml +7 -7
- package/.github/workflows/ci.yml +4 -4
- package/.jules/backlog_maniac.md +1 -0
- package/.jules/nexus.md +1 -0
- package/.jules/sentinel.md +1 -0
- package/.releaserc.json +2 -7
- package/AGENTS.md +21 -16
- package/CHANGELOG.md +192 -174
- package/README.md +95 -88
- package/coverage/coverage-final.json +9 -9
- package/coverage/index.html +9 -9
- package/coverage/lcov-report/index.html +9 -9
- package/coverage/lcov-report/src/EventBus.ts.html +30 -24
- package/coverage/lcov-report/src/TaskGraphValidationError.ts.html +12 -3
- package/coverage/lcov-report/src/TaskGraphValidator.ts.html +152 -137
- package/coverage/lcov-report/src/TaskRunner.ts.html +48 -45
- 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 +82 -52
- package/coverage/lcov-report/src/WorkflowExecutor.ts.html +210 -66
- 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 +16 -16
- package/coverage/lcov-report/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
- package/coverage/lcov-report/src/strategies/RetryingExecutionStrategy.ts.html +29 -11
- package/coverage/lcov-report/src/strategies/StandardExecutionStrategy.ts.html +7 -7
- package/coverage/lcov-report/src/strategies/index.html +1 -1
- package/coverage/lcov.info +426 -383
- package/coverage/src/EventBus.ts.html +30 -24
- package/coverage/src/TaskGraphValidationError.ts.html +12 -3
- package/coverage/src/TaskGraphValidator.ts.html +152 -137
- package/coverage/src/TaskRunner.ts.html +48 -45
- package/coverage/src/TaskRunnerBuilder.ts.html +29 -5
- package/coverage/src/TaskRunnerExecutionConfig.ts.html +1 -1
- package/coverage/src/TaskStateManager.ts.html +82 -52
- package/coverage/src/WorkflowExecutor.ts.html +210 -66
- package/coverage/src/contracts/RunnerEvents.ts.html +1 -1
- package/coverage/src/contracts/index.html +1 -1
- package/coverage/src/index.html +16 -16
- package/coverage/src/strategies/DryRunExecutionStrategy.ts.html +4 -4
- package/coverage/src/strategies/RetryingExecutionStrategy.ts.html +29 -11
- package/coverage/src/strategies/StandardExecutionStrategy.ts.html +7 -7
- package/coverage/src/strategies/index.html +1 -1
- package/dist/EventBus.js +13 -11
- package/dist/EventBus.js.map +1 -1
- package/dist/TaskGraphValidationError.js.map +1 -1
- package/dist/TaskGraphValidator.js +9 -9
- package/dist/TaskGraphValidator.js.map +1 -1
- package/dist/TaskRunner.js.map +1 -1
- package/dist/TaskRunnerBuilder.js.map +1 -1
- package/dist/TaskStateManager.d.ts +6 -0
- package/dist/TaskStateManager.js +11 -2
- package/dist/TaskStateManager.js.map +1 -1
- package/dist/TaskStep.d.ts +5 -0
- package/dist/WorkflowExecutor.js +49 -7
- package/dist/WorkflowExecutor.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/archive/2026-01-18-add-concurrency-control/proposal.md +7 -4
- package/openspec/changes/archive/2026-01-18-add-concurrency-control/tasks.md +1 -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-feat-conditional-execution/proposal.md +35 -0
- package/openspec/changes/archive/2026-01-18-feat-conditional-execution/tasks.md +32 -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 +2 -1
- package/src/EventBus.ts +18 -16
- package/src/TaskGraph.ts +8 -8
- package/src/TaskGraphValidationError.ts +4 -1
- package/src/TaskGraphValidator.ts +148 -143
- package/src/TaskRunner.ts +42 -41
- package/src/TaskRunnerBuilder.ts +11 -3
- package/src/TaskStateManager.ts +12 -2
- package/src/TaskStep.ts +6 -0
- package/src/WorkflowExecutor.ts +63 -15
- package/src/contracts/ITaskGraphValidator.ts +12 -12
- package/src/contracts/ValidationError.ts +6 -6
- package/src/contracts/ValidationResult.ts +4 -4
- 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 +132 -108
package/openspec/project.md
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# Project: Task Runner
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
|
+
|
|
4
5
|
The 'task-runner' project is a TypeScript-based utility designed to manage and execute tasks. It incorporates features such as task cancellation, pre-execution validation, and concurrency control, providing a robust framework for workflow automation.
|
|
5
6
|
|
|
6
7
|
## Tech Stack
|
|
8
|
+
|
|
7
9
|
- **Languages:** TypeScript 5.9.3
|
|
8
10
|
- **Testing:** Vitest 4.0.17
|
|
9
11
|
- **Core APIs:** AbortSignal/AbortController (Standard Web APIs for cancellation)
|
|
10
12
|
- **Package Manager:** pnpm
|
|
11
13
|
|
|
12
14
|
## Architecture
|
|
15
|
+
|
|
13
16
|
The project follows a modular architecture with distinct components for managing different aspects of task execution:
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
17
|
+
|
|
18
|
+
- `EventBus.ts`: Handles event propagation within the system.
|
|
19
|
+
- `TaskGraph.ts`: Represents the structure and dependencies of tasks.
|
|
20
|
+
- `TaskGraphValidator.ts`: Ensures the validity of task graphs before execution.
|
|
21
|
+
- `TaskRunner.ts`: Orchestrates the execution of tasks.
|
|
22
|
+
- `WorkflowExecutor.ts`: Manages the overall workflow.
|
|
23
|
+
- `contracts/`: Defines interfaces and types for various components, promoting loose coupling and clear API boundaries.
|
|
20
24
|
|
|
21
25
|
## Conventions
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
26
|
+
|
|
27
|
+
- **Coding Style:** Adheres to standard TypeScript conventions, enforced by ESLint and Prettier.
|
|
28
|
+
- **Commit Messages:** Follows conventional commits enforced by Commitlint.
|
|
29
|
+
- **Git Hooks:** Utilizes Husky for pre-commit and commit-msg hooks.
|
|
30
|
+
- **Testing:** Uses Vitest for unit and integration testing.
|
|
31
|
+
- **Atomic Commits:** When working on complex multi-task features, commit after each distinct task, ensuring build, lint, and test success to establish safe rollback points.
|
|
27
32
|
|
|
28
33
|
## Build/Test/Run Commands
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
34
|
+
|
|
35
|
+
- **Install Dependencies:** `pnpm install`
|
|
36
|
+
- **Build Project:** `pnpm build`
|
|
37
|
+
- **Run Tests:** `pnpm test`
|
|
38
|
+
- **Lint Code:** `pnpm lint`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calmo/task-runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"test": "tsc --noEmit -p tsconfig.test.json && vitest run --coverage",
|
|
21
21
|
"lint": "eslint .",
|
|
22
22
|
"lint:fix": "eslint . --fix",
|
|
23
|
+
"all": "pnpm run build && pnpm run test && pnpm run lint",
|
|
23
24
|
"format": "prettier --write .",
|
|
24
25
|
"prepare": "husky",
|
|
25
26
|
"commit": "git add . &&git-cz"
|
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
|
}
|
|
@@ -5,7 +5,10 @@ import { ValidationResult } from "./contracts/ValidationResult.js";
|
|
|
5
5
|
* Contains the validation result with detailed error information.
|
|
6
6
|
*/
|
|
7
7
|
export class TaskGraphValidationError extends Error {
|
|
8
|
-
constructor(
|
|
8
|
+
constructor(
|
|
9
|
+
public result: ValidationResult,
|
|
10
|
+
message: string
|
|
11
|
+
) {
|
|
9
12
|
super(message);
|
|
10
13
|
this.name = "TaskGraphValidationError";
|
|
11
14
|
}
|
|
@@ -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,7 +2,10 @@ 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";
|
|
@@ -24,9 +27,8 @@ export { RunnerEventPayloads, RunnerEventListener, TaskRunnerExecutionConfig };
|
|
|
24
27
|
export class TaskRunner<TContext> {
|
|
25
28
|
private eventBus = new EventBus<TContext>();
|
|
26
29
|
private validator = new TaskGraphValidator();
|
|
27
|
-
private executionStrategy: IExecutionStrategy<TContext> =
|
|
28
|
-
new StandardExecutionStrategy()
|
|
29
|
-
);
|
|
30
|
+
private executionStrategy: IExecutionStrategy<TContext> =
|
|
31
|
+
new RetryingExecutionStrategy(new StandardExecutionStrategy());
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* @param context The shared context object to be passed to each task.
|
|
@@ -85,7 +87,6 @@ export class TaskRunner<TContext> {
|
|
|
85
87
|
const safeId = (name: string) => JSON.stringify(name);
|
|
86
88
|
const sanitize = (name: string) => this.sanitizeMermaidId(name);
|
|
87
89
|
|
|
88
|
-
|
|
89
90
|
// Add all nodes first to ensure they exist
|
|
90
91
|
for (const step of steps) {
|
|
91
92
|
// Using the name as both ID and Label for simplicity
|
|
@@ -98,9 +99,7 @@ export class TaskRunner<TContext> {
|
|
|
98
99
|
for (const step of steps) {
|
|
99
100
|
if (step.dependencies) {
|
|
100
101
|
for (const dep of step.dependencies) {
|
|
101
|
-
graphLines.push(
|
|
102
|
-
` ${sanitize(dep)} --> ${sanitize(step.name)}`
|
|
103
|
-
);
|
|
102
|
+
graphLines.push(` ${sanitize(dep)} --> ${sanitize(step.name)}`);
|
|
104
103
|
}
|
|
105
104
|
}
|
|
106
105
|
}
|
|
@@ -162,40 +161,42 @@ export class TaskRunner<TContext> {
|
|
|
162
161
|
|
|
163
162
|
// We need to handle the timeout cleanup properly.
|
|
164
163
|
if (config?.timeout !== undefined) {
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
197
198
|
} else {
|
|
198
|
-
|
|
199
|
+
return executor.execute(steps, config?.signal);
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
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/TaskStateManager.ts
CHANGED
|
@@ -55,8 +55,7 @@ export class TaskStateManager<TContext> {
|
|
|
55
55
|
status: "skipped",
|
|
56
56
|
message: `Skipped because dependency '${failedDep}' failed${depError}`,
|
|
57
57
|
};
|
|
58
|
-
this.
|
|
59
|
-
this.eventBus.emit("taskSkipped", { step, result });
|
|
58
|
+
this.markSkipped(step, result);
|
|
60
59
|
toRemove.push(step);
|
|
61
60
|
} else if (!blocked) {
|
|
62
61
|
toRun.push(step);
|
|
@@ -139,4 +138,15 @@ export class TaskStateManager<TContext> {
|
|
|
139
138
|
hasPendingTasks(): boolean {
|
|
140
139
|
return this.pendingSteps.size > 0;
|
|
141
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Marks a task as skipped and emits `taskSkipped`.
|
|
144
|
+
* @param step The task that was skipped.
|
|
145
|
+
* @param result The result object (status: skipped).
|
|
146
|
+
*/
|
|
147
|
+
markSkipped(step: TaskStep<TContext>, result: TaskResult): void {
|
|
148
|
+
this.running.delete(step.name);
|
|
149
|
+
this.results.set(step.name, result);
|
|
150
|
+
this.eventBus.emit("taskSkipped", { step, result });
|
|
151
|
+
}
|
|
142
152
|
}
|
package/src/TaskStep.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface TaskStep<TContext> {
|
|
|
12
12
|
dependencies?: string[];
|
|
13
13
|
/** Optional retry configuration for the task. */
|
|
14
14
|
retry?: TaskRetryConfig;
|
|
15
|
+
/**
|
|
16
|
+
* Optional function to determine if the task should run.
|
|
17
|
+
* If it returns false (synchronously or asynchronously), the task is skipped.
|
|
18
|
+
*/
|
|
19
|
+
condition?: (context: TContext) => boolean | Promise<boolean>;
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* The core logic of the task.
|
|
17
23
|
* @param context The shared context object, allowing for state to be passed between tasks.
|