@calmo/task-runner 2.0.0 → 3.0.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/AGENTS.md +49 -0
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/coverage/coverage-final.json +8 -4
- package/coverage/index.html +24 -9
- package/coverage/lcov-report/index.html +24 -9
- package/coverage/lcov-report/src/EventBus.ts.html +8 -8
- package/coverage/lcov-report/src/TaskGraphValidator.ts.html +53 -53
- package/coverage/lcov-report/src/TaskRunner.ts.html +213 -21
- package/coverage/lcov-report/src/TaskRunnerBuilder.ts.html +313 -0
- package/coverage/lcov-report/src/TaskRunnerExecutionConfig.ts.html +124 -0
- package/coverage/lcov-report/src/TaskStateManager.ts.html +511 -0
- package/coverage/lcov-report/src/WorkflowExecutor.ts.html +119 -137
- 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 +57 -12
- package/coverage/lcov-report/src/strategies/StandardExecutionStrategy.ts.html +190 -0
- package/coverage/lcov-report/src/strategies/index.html +116 -0
- package/coverage/lcov.info +381 -204
- package/coverage/src/EventBus.ts.html +8 -8
- package/coverage/src/TaskGraphValidator.ts.html +53 -53
- package/coverage/src/TaskRunner.ts.html +213 -21
- package/coverage/src/TaskRunnerBuilder.ts.html +313 -0
- package/coverage/src/TaskRunnerExecutionConfig.ts.html +124 -0
- package/coverage/src/TaskStateManager.ts.html +511 -0
- package/coverage/src/WorkflowExecutor.ts.html +119 -137
- package/coverage/src/contracts/RunnerEvents.ts.html +1 -1
- package/coverage/src/contracts/index.html +1 -1
- package/coverage/src/index.html +57 -12
- package/coverage/src/strategies/StandardExecutionStrategy.ts.html +190 -0
- package/coverage/src/strategies/index.html +116 -0
- package/dist/TaskRunner.d.ts +12 -2
- package/dist/TaskRunner.js +55 -3
- package/dist/TaskRunner.js.map +1 -1
- package/dist/TaskRunnerBuilder.d.ts +33 -0
- package/dist/TaskRunnerBuilder.js +54 -0
- package/dist/TaskRunnerBuilder.js.map +1 -0
- package/dist/TaskRunnerExecutionConfig.d.ts +13 -0
- package/dist/TaskRunnerExecutionConfig.js +2 -0
- package/dist/TaskRunnerExecutionConfig.js.map +1 -0
- package/dist/TaskStateManager.d.ts +54 -0
- package/dist/TaskStateManager.js +130 -0
- package/dist/TaskStateManager.js.map +1 -0
- package/dist/TaskStatus.d.ts +1 -1
- package/dist/TaskStep.d.ts +2 -1
- package/dist/WorkflowExecutor.d.ts +11 -17
- package/dist/WorkflowExecutor.js +67 -69
- package/dist/WorkflowExecutor.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/strategies/IExecutionStrategy.d.ts +16 -0
- package/dist/strategies/IExecutionStrategy.js +2 -0
- package/dist/strategies/IExecutionStrategy.js.map +1 -0
- package/dist/strategies/StandardExecutionStrategy.d.ts +9 -0
- package/dist/strategies/StandardExecutionStrategy.js +25 -0
- package/dist/strategies/StandardExecutionStrategy.js.map +1 -0
- package/openspec/changes/add-concurrency-control/proposal.md +14 -0
- package/openspec/changes/add-concurrency-control/tasks.md +9 -0
- package/openspec/changes/add-task-retry-policy/proposal.md +13 -0
- package/openspec/changes/add-task-retry-policy/tasks.md +10 -0
- package/openspec/changes/add-workflow-preview/proposal.md +12 -0
- package/openspec/changes/add-workflow-preview/tasks.md +7 -0
- package/openspec/changes/archive/2026-01-18-add-external-task-cancellation/tasks.md +10 -0
- package/openspec/changes/archive/2026-01-18-add-integration-tests/proposal.md +18 -0
- package/openspec/changes/archive/2026-01-18-add-integration-tests/tasks.md +17 -0
- package/openspec/changes/archive/2026-01-18-refactor-core-architecture/proposal.md +14 -0
- package/openspec/changes/archive/2026-01-18-refactor-core-architecture/tasks.md +8 -0
- package/package.json +1 -1
- package/src/TaskRunner.ts +68 -4
- package/src/TaskRunnerBuilder.ts +76 -0
- package/src/TaskRunnerExecutionConfig.ts +13 -0
- package/src/TaskStateManager.ts +142 -0
- package/src/TaskStatus.ts +1 -1
- package/src/TaskStep.ts +2 -1
- package/src/WorkflowExecutor.ts +77 -83
- package/src/index.ts +4 -0
- package/src/strategies/IExecutionStrategy.ts +21 -0
- package/src/strategies/StandardExecutionStrategy.ts +35 -0
- package/test-report.xml +119 -45
- package/GEMINI.md +0 -48
- package/openspec/changes/add-external-task-cancellation/tasks.md +0 -10
- /package/openspec/changes/{add-external-task-cancellation → archive/2026-01-18-add-external-task-cancellation}/proposal.md +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Implementation
|
|
2
|
+
- [x] 1.1 Extract `TaskStateManager` to handle `TaskResult` storage, updates, and context mutations.
|
|
3
|
+
- [x] 1.2 Define `IExecutionStrategy` interface for running tasks (Strategy Pattern).
|
|
4
|
+
- [x] 1.3 Refactor `WorkflowExecutor` to use `TaskStateManager` and `IExecutionStrategy`.
|
|
5
|
+
- [x] 1.4 Move explicit event emission out of the core loop into the `TaskStateManager` or a dedicated `ExecutionObserver`.
|
|
6
|
+
- [x] 1.5 Create a factory or builder for `TaskRunner` to simplify configuration for developers.
|
|
7
|
+
- [x] 1.6 Verify that all existing tests pass with the new structure.
|
|
8
|
+
- [x] 1.7 Add "Quality of Life" improvements (e.g., better error messages with specific task context).
|
package/package.json
CHANGED
package/src/TaskRunner.ts
CHANGED
|
@@ -5,9 +5,13 @@ import { TaskGraph } from "./TaskGraph.js";
|
|
|
5
5
|
import { RunnerEventPayloads, RunnerEventListener } from "./contracts/RunnerEvents.js";
|
|
6
6
|
import { EventBus } from "./EventBus.js";
|
|
7
7
|
import { WorkflowExecutor } from "./WorkflowExecutor.js";
|
|
8
|
+
import { TaskRunnerExecutionConfig } from "./TaskRunnerExecutionConfig.js";
|
|
9
|
+
import { TaskStateManager } from "./TaskStateManager.js";
|
|
10
|
+
import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
|
|
11
|
+
import { StandardExecutionStrategy } from "./strategies/StandardExecutionStrategy.js";
|
|
8
12
|
|
|
9
13
|
// Re-export types for backward compatibility
|
|
10
|
-
export { RunnerEventPayloads, RunnerEventListener };
|
|
14
|
+
export { RunnerEventPayloads, RunnerEventListener, TaskRunnerExecutionConfig };
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* The main class that orchestrates the execution of a list of tasks
|
|
@@ -17,6 +21,7 @@ export { RunnerEventPayloads, RunnerEventListener };
|
|
|
17
21
|
export class TaskRunner<TContext> {
|
|
18
22
|
private eventBus = new EventBus<TContext>();
|
|
19
23
|
private validator = new TaskGraphValidator();
|
|
24
|
+
private executionStrategy: IExecutionStrategy<TContext> = new StandardExecutionStrategy();
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* @param context The shared context object to be passed to each task.
|
|
@@ -44,17 +49,33 @@ export class TaskRunner<TContext> {
|
|
|
44
49
|
event: K,
|
|
45
50
|
callback: RunnerEventListener<TContext, K>
|
|
46
51
|
): void {
|
|
52
|
+
/* v8 ignore next 1 */
|
|
47
53
|
this.eventBus.off(event, callback);
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Sets the execution strategy to be used.
|
|
58
|
+
* @param strategy The execution strategy.
|
|
59
|
+
* @returns The TaskRunner instance for chaining.
|
|
60
|
+
*/
|
|
61
|
+
public setExecutionStrategy(strategy: IExecutionStrategy<TContext>): this {
|
|
62
|
+
/* v8 ignore next 2 */
|
|
63
|
+
this.executionStrategy = strategy;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/**
|
|
51
68
|
* Executes a list of tasks, respecting their dependencies and running
|
|
52
69
|
* independent tasks in parallel.
|
|
53
70
|
* @param steps An array of TaskStep objects to be executed.
|
|
71
|
+
* @param config Optional configuration for execution (timeout, cancellation).
|
|
54
72
|
* @returns A Promise that resolves to a Map where keys are task names
|
|
55
73
|
* and values are the corresponding TaskResult objects.
|
|
56
74
|
*/
|
|
57
|
-
async execute(
|
|
75
|
+
async execute(
|
|
76
|
+
steps: TaskStep<TContext>[],
|
|
77
|
+
config?: TaskRunnerExecutionConfig
|
|
78
|
+
): Promise<Map<string, TaskResult>> {
|
|
58
79
|
// Validate the task graph before execution
|
|
59
80
|
const taskGraph: TaskGraph = {
|
|
60
81
|
tasks: steps.map((step) => ({
|
|
@@ -68,7 +89,50 @@ export class TaskRunner<TContext> {
|
|
|
68
89
|
throw new Error(this.validator.createErrorMessage(validationResult));
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
const
|
|
72
|
-
|
|
92
|
+
const stateManager = new TaskStateManager(this.eventBus);
|
|
93
|
+
const executor = new WorkflowExecutor(
|
|
94
|
+
this.context,
|
|
95
|
+
this.eventBus,
|
|
96
|
+
stateManager,
|
|
97
|
+
this.executionStrategy
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// We need to handle the timeout cleanup properly.
|
|
101
|
+
if (config?.timeout !== undefined) {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timeoutId = setTimeout(() => {
|
|
104
|
+
controller.abort(new Error(`Workflow timed out after ${config.timeout}ms`));
|
|
105
|
+
}, config.timeout);
|
|
106
|
+
|
|
107
|
+
let effectiveSignal = controller.signal;
|
|
108
|
+
let onAbort: (() => void) | undefined;
|
|
109
|
+
|
|
110
|
+
// Handle combination of signals if user provided one
|
|
111
|
+
if (config.signal) {
|
|
112
|
+
if (config.signal.aborted) {
|
|
113
|
+
// If already aborted, use it directly (WorkflowExecutor handles early abort)
|
|
114
|
+
// We can cancel timeout immediately
|
|
115
|
+
clearTimeout(timeoutId);
|
|
116
|
+
effectiveSignal = config.signal;
|
|
117
|
+
} else {
|
|
118
|
+
// Listen to user signal to abort our controller
|
|
119
|
+
onAbort = () => {
|
|
120
|
+
controller.abort(config.signal?.reason);
|
|
121
|
+
};
|
|
122
|
+
config.signal.addEventListener("abort", onAbort);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
return await executor.execute(steps, effectiveSignal);
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timeoutId);
|
|
130
|
+
if (config.signal && onAbort) {
|
|
131
|
+
config.signal.removeEventListener("abort", onAbort);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
return executor.execute(steps, config?.signal);
|
|
136
|
+
}
|
|
73
137
|
}
|
|
74
138
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TaskRunner } from "./TaskRunner.js";
|
|
2
|
+
import { RunnerEventPayloads, RunnerEventListener } from "./contracts/RunnerEvents.js";
|
|
3
|
+
import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A builder for configuring and creating TaskRunner instances.
|
|
7
|
+
*/
|
|
8
|
+
export class TaskRunnerBuilder<TContext> {
|
|
9
|
+
private context: TContext;
|
|
10
|
+
private strategy?: IExecutionStrategy<TContext>;
|
|
11
|
+
private listeners: {
|
|
12
|
+
[K in keyof RunnerEventPayloads<TContext>]?: RunnerEventListener<TContext, K>[];
|
|
13
|
+
} = {};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param context The shared context object.
|
|
17
|
+
*/
|
|
18
|
+
constructor(context: TContext) {
|
|
19
|
+
this.context = context;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sets the execution strategy.
|
|
24
|
+
* @param strategy The execution strategy to use.
|
|
25
|
+
* @returns The builder instance.
|
|
26
|
+
*/
|
|
27
|
+
public useStrategy(strategy: IExecutionStrategy<TContext>): this {
|
|
28
|
+
this.strategy = strategy;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Adds an event listener.
|
|
34
|
+
* @param event The event name.
|
|
35
|
+
* @param callback The callback to execute.
|
|
36
|
+
* @returns The builder instance.
|
|
37
|
+
*/
|
|
38
|
+
public on<K extends keyof RunnerEventPayloads<TContext>>(
|
|
39
|
+
event: K,
|
|
40
|
+
callback: RunnerEventListener<TContext, K>
|
|
41
|
+
): this {
|
|
42
|
+
if (!this.listeners[event]) {
|
|
43
|
+
this.listeners[event] = [];
|
|
44
|
+
}
|
|
45
|
+
this.listeners[event]!.push(callback);
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds the TaskRunner instance.
|
|
51
|
+
* @returns A configured TaskRunner.
|
|
52
|
+
*/
|
|
53
|
+
public build(): TaskRunner<TContext> {
|
|
54
|
+
const runner = new TaskRunner(this.context);
|
|
55
|
+
|
|
56
|
+
if (this.strategy) {
|
|
57
|
+
runner.setExecutionStrategy(this.strategy);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
(Object.keys(this.listeners) as Array<keyof RunnerEventPayloads<TContext>>).forEach((event) => {
|
|
61
|
+
const callbacks = this.listeners[event];
|
|
62
|
+
// callbacks is always defined because we are iterating keys of the object
|
|
63
|
+
callbacks!.forEach((callback) =>
|
|
64
|
+
runner.on(
|
|
65
|
+
event,
|
|
66
|
+
callback as unknown as RunnerEventListener<
|
|
67
|
+
TContext,
|
|
68
|
+
keyof RunnerEventPayloads<TContext>
|
|
69
|
+
>
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return runner;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration options for TaskRunner execution.
|
|
3
|
+
*/
|
|
4
|
+
export interface TaskRunnerExecutionConfig {
|
|
5
|
+
/**
|
|
6
|
+
* An AbortSignal to cancel the workflow externally.
|
|
7
|
+
*/
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
/**
|
|
10
|
+
* A timeout in milliseconds for the entire workflow.
|
|
11
|
+
*/
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { TaskStep } from "./TaskStep.js";
|
|
2
|
+
import { TaskResult } from "./TaskResult.js";
|
|
3
|
+
import { EventBus } from "./EventBus.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages the state of the task execution, including results, pending steps, and running tasks.
|
|
7
|
+
* Handles dependency resolution and event emission for state changes.
|
|
8
|
+
*/
|
|
9
|
+
export class TaskStateManager<TContext> {
|
|
10
|
+
private results = new Map<string, TaskResult>();
|
|
11
|
+
private pendingSteps = new Set<TaskStep<TContext>>();
|
|
12
|
+
private running = new Set<string>();
|
|
13
|
+
|
|
14
|
+
constructor(private eventBus: EventBus<TContext>) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initializes the state with the given steps.
|
|
18
|
+
* @param steps The steps to execute.
|
|
19
|
+
*/
|
|
20
|
+
initialize(steps: TaskStep<TContext>[]): void {
|
|
21
|
+
this.pendingSteps = new Set(steps);
|
|
22
|
+
this.results.clear();
|
|
23
|
+
this.running.clear();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Processes the pending steps to identify tasks that can be started or must be skipped.
|
|
28
|
+
* Emits `taskSkipped` for skipped tasks.
|
|
29
|
+
* @returns An array of tasks that are ready to run.
|
|
30
|
+
*/
|
|
31
|
+
processDependencies(): TaskStep<TContext>[] {
|
|
32
|
+
const toRemove: TaskStep<TContext>[] = [];
|
|
33
|
+
const toRun: TaskStep<TContext>[] = [];
|
|
34
|
+
|
|
35
|
+
for (const step of this.pendingSteps) {
|
|
36
|
+
const deps = step.dependencies ?? [];
|
|
37
|
+
let blocked = false;
|
|
38
|
+
let failedDep: string | undefined;
|
|
39
|
+
|
|
40
|
+
for (const dep of deps) {
|
|
41
|
+
const depResult = this.results.get(dep);
|
|
42
|
+
if (!depResult) {
|
|
43
|
+
// Dependency not finished yet
|
|
44
|
+
blocked = true;
|
|
45
|
+
} else if (depResult.status !== "success") {
|
|
46
|
+
failedDep = dep;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (failedDep) {
|
|
52
|
+
const depResult = this.results.get(failedDep);
|
|
53
|
+
const depError = depResult?.error ? `: ${depResult.error}` : "";
|
|
54
|
+
const result: TaskResult = {
|
|
55
|
+
status: "skipped",
|
|
56
|
+
message: `Skipped because dependency '${failedDep}' failed${depError}`,
|
|
57
|
+
};
|
|
58
|
+
this.results.set(step.name, result);
|
|
59
|
+
this.eventBus.emit("taskSkipped", { step, result });
|
|
60
|
+
toRemove.push(step);
|
|
61
|
+
} else if (!blocked) {
|
|
62
|
+
toRun.push(step);
|
|
63
|
+
toRemove.push(step);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cleanup pending set
|
|
68
|
+
for (const step of toRemove) {
|
|
69
|
+
this.pendingSteps.delete(step);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return toRun;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Marks a task as running and emits `taskStart`.
|
|
77
|
+
* @param step The task that is starting.
|
|
78
|
+
*/
|
|
79
|
+
markRunning(step: TaskStep<TContext>): void {
|
|
80
|
+
this.running.add(step.name);
|
|
81
|
+
this.eventBus.emit("taskStart", { step });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Marks a task as completed (success, failure, or cancelled during execution)
|
|
86
|
+
* and emits `taskEnd`.
|
|
87
|
+
* @param step The task that completed.
|
|
88
|
+
* @param result The result of the task.
|
|
89
|
+
*/
|
|
90
|
+
markCompleted(step: TaskStep<TContext>, result: TaskResult): void {
|
|
91
|
+
this.running.delete(step.name);
|
|
92
|
+
this.results.set(step.name, result);
|
|
93
|
+
this.eventBus.emit("taskEnd", { step, result });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Cancels all pending tasks that haven't started yet.
|
|
98
|
+
* @param message The cancellation message.
|
|
99
|
+
*/
|
|
100
|
+
cancelAllPending(message: string): void {
|
|
101
|
+
// Iterate over pendingSteps to cancel them
|
|
102
|
+
for (const step of this.pendingSteps) {
|
|
103
|
+
// Also check running? No, running tasks are handled by AbortSignal in Executor.
|
|
104
|
+
// We only cancel what is pending and hasn't started.
|
|
105
|
+
/* v8 ignore next 1 */
|
|
106
|
+
if (!this.results.has(step.name) && !this.running.has(step.name)) {
|
|
107
|
+
const result: TaskResult = {
|
|
108
|
+
status: "cancelled",
|
|
109
|
+
message,
|
|
110
|
+
};
|
|
111
|
+
this.results.set(step.name, result);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Clear pending set as they are now "done" (cancelled)
|
|
115
|
+
// Wait, if we clear pending steps, processDependencies won't pick them up.
|
|
116
|
+
// The loop in Executor relies on results.size or pendingSteps.
|
|
117
|
+
// The previous implementation iterated `steps` (all steps) to cancel.
|
|
118
|
+
// Here we iterate `pendingSteps`.
|
|
119
|
+
this.pendingSteps.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the current results map.
|
|
124
|
+
*/
|
|
125
|
+
getResults(): Map<string, TaskResult> {
|
|
126
|
+
return this.results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks if there are any tasks currently running.
|
|
131
|
+
*/
|
|
132
|
+
hasRunningTasks(): boolean {
|
|
133
|
+
return this.running.size > 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Checks if there are any pending tasks.
|
|
138
|
+
*/
|
|
139
|
+
hasPendingTasks(): boolean {
|
|
140
|
+
return this.pendingSteps.size > 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/TaskStatus.ts
CHANGED
package/src/TaskStep.ts
CHANGED
|
@@ -12,7 +12,8 @@ export interface TaskStep<TContext> {
|
|
|
12
12
|
/**
|
|
13
13
|
* The core logic of the task.
|
|
14
14
|
* @param context The shared context object, allowing for state to be passed between tasks.
|
|
15
|
+
* @param signal An optional AbortSignal to listen for cancellation.
|
|
15
16
|
* @returns A Promise that resolves to a TaskResult.
|
|
16
17
|
*/
|
|
17
|
-
run(context: TContext): Promise<TaskResult>;
|
|
18
|
+
run(context: TContext, signal?: AbortSignal): Promise<TaskResult>;
|
|
18
19
|
}
|
package/src/WorkflowExecutor.ts
CHANGED
|
@@ -1,126 +1,120 @@
|
|
|
1
1
|
import { TaskStep } from "./TaskStep.js";
|
|
2
2
|
import { TaskResult } from "./TaskResult.js";
|
|
3
3
|
import { EventBus } from "./EventBus.js";
|
|
4
|
+
import { TaskStateManager } from "./TaskStateManager.js";
|
|
5
|
+
import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Handles the execution of the workflow steps.
|
|
7
9
|
* @template TContext The shape of the shared context object.
|
|
8
10
|
*/
|
|
9
11
|
export class WorkflowExecutor<TContext> {
|
|
10
|
-
private running = new Set<string>();
|
|
11
|
-
|
|
12
12
|
/**
|
|
13
13
|
* @param context The shared context object.
|
|
14
14
|
* @param eventBus The event bus to emit events.
|
|
15
|
+
* @param stateManager Manages execution state.
|
|
16
|
+
* @param strategy Execution strategy.
|
|
15
17
|
*/
|
|
16
18
|
constructor(
|
|
17
19
|
private context: TContext,
|
|
18
|
-
private eventBus: EventBus<TContext
|
|
20
|
+
private eventBus: EventBus<TContext>,
|
|
21
|
+
private stateManager: TaskStateManager<TContext>,
|
|
22
|
+
private strategy: IExecutionStrategy<TContext>
|
|
19
23
|
) {}
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Executes the given steps.
|
|
23
27
|
* @param steps The list of steps to execute.
|
|
28
|
+
* @param signal Optional AbortSignal for cancellation.
|
|
24
29
|
* @returns A Promise that resolves to a map of task results.
|
|
25
30
|
*/
|
|
26
|
-
async execute(
|
|
31
|
+
async execute(
|
|
32
|
+
steps: TaskStep<TContext>[],
|
|
33
|
+
signal?: AbortSignal
|
|
34
|
+
): Promise<Map<string, TaskResult>> {
|
|
27
35
|
this.eventBus.emit("workflowStart", { context: this.context, steps });
|
|
36
|
+
this.stateManager.initialize(steps);
|
|
37
|
+
|
|
38
|
+
// Check if already aborted
|
|
39
|
+
if (signal?.aborted) {
|
|
40
|
+
this.stateManager.cancelAllPending("Workflow cancelled before execution started.");
|
|
41
|
+
const results = this.stateManager.getResults();
|
|
42
|
+
this.eventBus.emit("workflowEnd", { context: this.context, results });
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
28
45
|
|
|
29
|
-
const results = new Map<string, TaskResult>();
|
|
30
46
|
const executingPromises = new Set<Promise<void>>();
|
|
31
47
|
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
const onAbort = () => {
|
|
49
|
+
// Mark all pending tasks as cancelled
|
|
50
|
+
this.stateManager.cancelAllPending("Workflow cancelled.");
|
|
51
|
+
};
|
|
34
52
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
await Promise.race(executingPromises);
|
|
38
|
-
// After a task finishes, check for new work
|
|
39
|
-
this.processQueue(steps, results, executingPromises);
|
|
53
|
+
if (signal) {
|
|
54
|
+
signal.addEventListener("abort", onAbort);
|
|
40
55
|
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
* Identifies steps that cannot run because a dependency failed.
|
|
68
|
-
*/
|
|
69
|
-
private handleSkippedTasks(steps: TaskStep<TContext>[], results: Map<string, TaskResult>): void {
|
|
70
|
-
const pendingSteps = steps.filter(
|
|
71
|
-
(step) => !results.has(step.name) && !this.running.has(step.name)
|
|
72
|
-
);
|
|
57
|
+
try {
|
|
58
|
+
// Initial pass
|
|
59
|
+
this.processLoop(executingPromises, signal);
|
|
60
|
+
|
|
61
|
+
while (
|
|
62
|
+
this.stateManager.hasPendingTasks() ||
|
|
63
|
+
this.stateManager.hasRunningTasks()
|
|
64
|
+
) {
|
|
65
|
+
// Safety check: if no tasks are running and we still have pending tasks,
|
|
66
|
+
// it means we are stuck (e.g. cycle or unhandled dependency).
|
|
67
|
+
// Since valid graphs shouldn't have this, we break to avoid infinite loop.
|
|
68
|
+
if (executingPromises.size === 0) {
|
|
69
|
+
break;
|
|
70
|
+
} else {
|
|
71
|
+
// Wait for the next task to finish
|
|
72
|
+
await Promise.race(executingPromises);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (signal?.aborted) {
|
|
76
|
+
this.stateManager.cancelAllPending("Workflow cancelled.");
|
|
77
|
+
} else {
|
|
78
|
+
// After a task finishes, check for new work
|
|
79
|
+
this.processLoop(executingPromises, signal);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const failedDep = deps.find(
|
|
77
|
-
(dep) => results.has(dep) && results.get(dep)?.status !== "success"
|
|
78
|
-
);
|
|
83
|
+
// Ensure everything is accounted for (e.g. if loop exited early)
|
|
84
|
+
this.stateManager.cancelAllPending("Workflow cancelled.");
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.eventBus.emit("taskSkipped", { step, result });
|
|
86
|
+
const results = this.stateManager.getResults();
|
|
87
|
+
this.eventBus.emit("workflowEnd", { context: this.context, results });
|
|
88
|
+
return results;
|
|
89
|
+
} finally {
|
|
90
|
+
if (signal) {
|
|
91
|
+
signal.removeEventListener("abort", onAbort);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
92
|
-
*
|
|
97
|
+
* Logic to identify tasks that can be started and run them.
|
|
93
98
|
*/
|
|
94
|
-
private
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
private processLoop(
|
|
100
|
+
executingPromises: Set<Promise<void>>,
|
|
101
|
+
signal?: AbortSignal
|
|
102
|
+
): void {
|
|
103
|
+
const toRun = this.stateManager.processDependencies();
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
105
|
+
// Execute ready tasks
|
|
106
|
+
for (const step of toRun) {
|
|
107
|
+
this.stateManager.markRunning(step);
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const taskPromise = this.strategy.execute(step, this.context, signal)
|
|
110
|
+
.then((result) => {
|
|
111
|
+
this.stateManager.markCompleted(step, result);
|
|
112
|
+
})
|
|
113
|
+
.finally(() => {
|
|
114
|
+
executingPromises.delete(taskPromise);
|
|
115
|
+
});
|
|
111
116
|
|
|
112
|
-
|
|
113
|
-
const result = await step.run(this.context);
|
|
114
|
-
results.set(step.name, result);
|
|
115
|
-
} catch (e) {
|
|
116
|
-
results.set(step.name, {
|
|
117
|
-
status: "failure",
|
|
118
|
-
error: e instanceof Error ? e.message : String(e),
|
|
119
|
-
});
|
|
120
|
-
} finally {
|
|
121
|
-
this.running.delete(step.name);
|
|
122
|
-
const result = results.get(step.name)!;
|
|
123
|
-
this.eventBus.emit("taskEnd", { step, result });
|
|
117
|
+
executingPromises.add(taskPromise);
|
|
124
118
|
}
|
|
125
119
|
}
|
|
126
120
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { TaskRunner } from "./TaskRunner.js";
|
|
2
|
+
export { TaskRunnerBuilder } from "./TaskRunnerBuilder.js";
|
|
3
|
+
export { TaskStateManager } from "./TaskStateManager.js";
|
|
4
|
+
export { StandardExecutionStrategy } from "./strategies/StandardExecutionStrategy.js";
|
|
5
|
+
export type { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
|
|
2
6
|
export type { TaskStep } from "./TaskStep.js";
|
|
3
7
|
export type { TaskResult } from "./TaskResult.js";
|
|
4
8
|
export type { TaskStatus } from "./TaskStatus.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TaskStep } from "../TaskStep.js";
|
|
2
|
+
import { TaskResult } from "../TaskResult.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface for task execution strategies.
|
|
6
|
+
* Allows different execution behaviors (e.g., standard, dry-run, debugging).
|
|
7
|
+
*/
|
|
8
|
+
export interface IExecutionStrategy<TContext> {
|
|
9
|
+
/**
|
|
10
|
+
* Executes a single task step.
|
|
11
|
+
* @param step The task step to execute.
|
|
12
|
+
* @param context The shared context.
|
|
13
|
+
* @param signal Optional abort signal.
|
|
14
|
+
* @returns A promise resolving to the task result.
|
|
15
|
+
*/
|
|
16
|
+
execute(
|
|
17
|
+
step: TaskStep<TContext>,
|
|
18
|
+
context: TContext,
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
): Promise<TaskResult>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { IExecutionStrategy } from "./IExecutionStrategy.js";
|
|
2
|
+
import { TaskStep } from "../TaskStep.js";
|
|
3
|
+
import { TaskResult } from "../TaskResult.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Standard execution strategy that runs the task's run method.
|
|
7
|
+
*/
|
|
8
|
+
export class StandardExecutionStrategy<TContext>
|
|
9
|
+
implements IExecutionStrategy<TContext>
|
|
10
|
+
{
|
|
11
|
+
async execute(
|
|
12
|
+
step: TaskStep<TContext>,
|
|
13
|
+
context: TContext,
|
|
14
|
+
signal?: AbortSignal
|
|
15
|
+
): Promise<TaskResult> {
|
|
16
|
+
try {
|
|
17
|
+
return await step.run(context, signal);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Check if error is due to abort
|
|
20
|
+
if (
|
|
21
|
+
signal?.aborted &&
|
|
22
|
+
(e instanceof Error && e.name === "AbortError" || signal.reason === e)
|
|
23
|
+
) {
|
|
24
|
+
return {
|
|
25
|
+
status: "cancelled",
|
|
26
|
+
message: "Task cancelled during execution",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
status: "failure",
|
|
31
|
+
error: e instanceof Error ? e.message : String(e),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|