@calmo/task-runner 4.1.0 → 4.2.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.
Files changed (60) hide show
  1. package/.jules/nexus.md +5 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +2 -0
  4. package/CHANGELOG.md +27 -0
  5. package/README.md +34 -0
  6. package/conductor/code_styleguides/general.md +23 -0
  7. package/conductor/code_styleguides/javascript.md +51 -0
  8. package/conductor/code_styleguides/typescript.md +43 -0
  9. package/conductor/product-guidelines.md +14 -0
  10. package/conductor/product.md +16 -0
  11. package/conductor/setup_state.json +1 -0
  12. package/conductor/tech-stack.md +19 -0
  13. package/conductor/workflow.md +334 -0
  14. package/dist/EventBus.js +16 -16
  15. package/dist/EventBus.js.map +1 -1
  16. package/dist/PluginManager.d.ts +22 -0
  17. package/dist/PluginManager.js +39 -0
  18. package/dist/PluginManager.js.map +1 -0
  19. package/dist/TaskGraphValidator.d.ts +1 -1
  20. package/dist/TaskGraphValidator.js +16 -21
  21. package/dist/TaskGraphValidator.js.map +1 -1
  22. package/dist/TaskRunner.d.ts +8 -1
  23. package/dist/TaskRunner.js +29 -19
  24. package/dist/TaskRunner.js.map +1 -1
  25. package/dist/TaskStateManager.js +9 -5
  26. package/dist/TaskStateManager.js.map +1 -1
  27. package/dist/WorkflowExecutor.js +19 -10
  28. package/dist/WorkflowExecutor.js.map +1 -1
  29. package/dist/contracts/Plugin.d.ts +30 -0
  30. package/dist/contracts/Plugin.js +2 -0
  31. package/dist/contracts/Plugin.js.map +1 -0
  32. package/openspec/changes/add-middleware-support/proposal.md +19 -0
  33. package/openspec/changes/add-middleware-support/specs/task-runner/spec.md +34 -0
  34. package/openspec/changes/add-middleware-support/tasks.md +9 -0
  35. package/openspec/changes/add-resource-concurrency/tasks.md +1 -0
  36. package/openspec/changes/allow-plugin-hooks/design.md +51 -0
  37. package/openspec/changes/allow-plugin-hooks/proposal.md +12 -0
  38. package/openspec/changes/allow-plugin-hooks/specs/post-task/spec.md +21 -0
  39. package/openspec/changes/allow-plugin-hooks/specs/pre-task/spec.md +32 -0
  40. package/openspec/changes/allow-plugin-hooks/tasks.md +7 -0
  41. package/openspec/changes/archive/2026-02-15-implement-plugin-system/design.md +45 -0
  42. package/openspec/changes/archive/2026-02-15-implement-plugin-system/proposal.md +13 -0
  43. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-context/spec.md +17 -0
  44. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-loading/spec.md +27 -0
  45. package/openspec/changes/archive/2026-02-15-implement-plugin-system/tasks.md +7 -0
  46. package/openspec/changes/feat-completion-dependencies/proposal.md +58 -0
  47. package/openspec/changes/feat-completion-dependencies/tasks.md +46 -0
  48. package/openspec/changes/feat-task-loop/proposal.md +22 -0
  49. package/openspec/changes/feat-task-loop/specs/task-runner/spec.md +34 -0
  50. package/openspec/changes/feat-task-loop/tasks.md +8 -0
  51. package/openspec/specs/plugin-context/spec.md +19 -0
  52. package/openspec/specs/plugin-loading/spec.md +29 -0
  53. package/package.json +1 -1
  54. package/src/EventBus.ts +7 -8
  55. package/src/PluginManager.ts +41 -0
  56. package/src/TaskGraphValidator.ts +22 -24
  57. package/src/TaskRunner.ts +36 -23
  58. package/src/TaskStateManager.ts +9 -5
  59. package/src/WorkflowExecutor.ts +24 -11
  60. package/src/contracts/Plugin.ts +32 -0
@@ -1,7 +1,7 @@
1
1
  import { ITaskGraphValidator } from "./contracts/ITaskGraphValidator.js";
2
2
  import { ValidationResult } from "./contracts/ValidationResult.js";
3
3
  import { ValidationError } from "./contracts/ValidationError.js";
4
- import { TaskGraph } from "./TaskGraph.js";
4
+ import { TaskGraph, Task } from "./TaskGraph.js";
5
5
  import {
6
6
  ERROR_CYCLE,
7
7
  ERROR_DUPLICATE_TASK,
@@ -22,11 +22,11 @@ export class TaskGraphValidator implements ITaskGraphValidator {
22
22
  validate(taskGraph: TaskGraph): ValidationResult {
23
23
  const errors: ValidationError[] = [];
24
24
 
25
- // 1. Check for duplicate tasks
26
- const taskIds = this.checkDuplicateTasks(taskGraph, errors);
25
+ // 1. Build Map and Check Duplicates (Single Pass)
26
+ const taskMap = this.buildTaskMapAndCheckDuplicates(taskGraph, errors);
27
27
 
28
28
  // 2. Check for missing dependencies
29
- this.checkMissingDependencies(taskGraph, taskIds, errors);
29
+ this.checkMissingDependencies(taskGraph, taskMap, errors);
30
30
 
31
31
  // 3. Check for cycles
32
32
  // Only run cycle detection if there are no missing dependencies, otherwise we might chase non-existent nodes.
@@ -35,7 +35,7 @@ export class TaskGraphValidator implements ITaskGraphValidator {
35
35
  );
36
36
 
37
37
  if (!hasMissingDependencies) {
38
- this.checkCycles(taskGraph, errors);
38
+ this.checkCycles(taskGraph, taskMap, errors);
39
39
  }
40
40
 
41
41
  return {
@@ -54,33 +54,33 @@ export class TaskGraphValidator implements ITaskGraphValidator {
54
54
  return `Task graph validation failed: ${errorDetails.join("; ")}`;
55
55
  }
56
56
 
57
- private checkDuplicateTasks(
57
+ private buildTaskMapAndCheckDuplicates(
58
58
  taskGraph: TaskGraph,
59
59
  errors: ValidationError[]
60
- ): Set<string> {
61
- const taskIds = new Set<string>();
60
+ ): Map<string, Task> {
61
+ const taskMap = new Map<string, Task>();
62
62
  for (const task of taskGraph.tasks) {
63
- if (taskIds.has(task.id)) {
63
+ if (taskMap.has(task.id)) {
64
64
  errors.push({
65
65
  type: ERROR_DUPLICATE_TASK,
66
66
  message: `Duplicate task detected with ID: ${task.id}`,
67
67
  details: { taskId: task.id },
68
68
  });
69
69
  } else {
70
- taskIds.add(task.id);
70
+ taskMap.set(task.id, task);
71
71
  }
72
72
  }
73
- return taskIds;
73
+ return taskMap;
74
74
  }
75
75
 
76
76
  private checkMissingDependencies(
77
77
  taskGraph: TaskGraph,
78
- taskIds: Set<string>,
78
+ taskMap: Map<string, Task>,
79
79
  errors: ValidationError[]
80
80
  ): void {
81
81
  for (const task of taskGraph.tasks) {
82
82
  for (const dependenceId of task.dependencies) {
83
- if (!taskIds.has(dependenceId)) {
83
+ if (!taskMap.has(dependenceId)) {
84
84
  errors.push({
85
85
  type: ERROR_MISSING_DEPENDENCY,
86
86
  message: `Task '${task.id}' depends on missing task '${dependenceId}'`,
@@ -91,13 +91,11 @@ export class TaskGraphValidator implements ITaskGraphValidator {
91
91
  }
92
92
  }
93
93
 
94
- private checkCycles(taskGraph: TaskGraph, errors: ValidationError[]): void {
95
- // Build adjacency list
96
- const adjacencyList = new Map<string, string[]>();
97
- for (const task of taskGraph.tasks) {
98
- adjacencyList.set(task.id, task.dependencies);
99
- }
100
-
94
+ private checkCycles(
95
+ taskGraph: TaskGraph,
96
+ taskMap: Map<string, Task>,
97
+ errors: ValidationError[]
98
+ ): void {
101
99
  const visited = new Set<string>();
102
100
  const recursionStack = new Set<string>();
103
101
 
@@ -108,7 +106,7 @@ export class TaskGraphValidator implements ITaskGraphValidator {
108
106
 
109
107
  const path: string[] = [];
110
108
  if (
111
- this.detectCycle(task.id, path, visited, recursionStack, adjacencyList)
109
+ this.detectCycle(task.id, path, visited, recursionStack, taskMap)
112
110
  ) {
113
111
  // Extract the actual cycle from the path
114
112
  // The path might look like A -> B -> C -> B (if we started at A and found cycle B-C-B)
@@ -132,7 +130,7 @@ export class TaskGraphValidator implements ITaskGraphValidator {
132
130
  path: string[],
133
131
  visited: Set<string>,
134
132
  recursionStack: Set<string>,
135
- adjacencyList: Map<string, string[]>
133
+ taskMap: Map<string, Task>
136
134
  ): boolean {
137
135
  // Use an explicit stack to avoid maximum call stack size exceeded errors
138
136
  const stack: { taskId: string; index: number; dependencies: string[] }[] =
@@ -145,7 +143,7 @@ export class TaskGraphValidator implements ITaskGraphValidator {
145
143
  stack.push({
146
144
  taskId: startTaskId,
147
145
  index: 0,
148
- dependencies: adjacencyList.get(startTaskId)!,
146
+ dependencies: taskMap.get(startTaskId)!.dependencies,
149
147
  });
150
148
 
151
149
  while (stack.length > 0) {
@@ -170,7 +168,7 @@ export class TaskGraphValidator implements ITaskGraphValidator {
170
168
  stack.push({
171
169
  taskId: dependenceId,
172
170
  index: 0,
173
- dependencies: adjacencyList.get(dependenceId)!,
171
+ dependencies: taskMap.get(dependenceId)!.dependencies,
174
172
  });
175
173
  }
176
174
  } else {
package/src/TaskRunner.ts CHANGED
@@ -14,11 +14,10 @@ import { TaskGraphValidationError } from "./TaskGraphValidationError.js";
14
14
  import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
15
15
  import { StandardExecutionStrategy } from "./strategies/StandardExecutionStrategy.js";
16
16
  import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrategy.js";
17
+ import { Plugin } from "./contracts/Plugin.js";
18
+ import { PluginManager } from "./PluginManager.js";
17
19
  import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js";
18
20
 
19
- // Re-export types for backward compatibility
20
- export { RunnerEventPayloads, RunnerEventListener, TaskRunnerExecutionConfig };
21
-
22
21
  /**
23
22
  * The main class that orchestrates the execution of a list of tasks
24
23
  * based on their dependencies, with support for parallel execution.
@@ -30,10 +29,14 @@ export class TaskRunner<TContext> {
30
29
  private executionStrategy: IExecutionStrategy<TContext> =
31
30
  new RetryingExecutionStrategy(new StandardExecutionStrategy());
32
31
 
32
+ private pluginManager: PluginManager<TContext>;
33
+
33
34
  /**
34
35
  * @param context The shared context object to be passed to each task.
35
36
  */
36
- constructor(private context: TContext) {}
37
+ constructor(private context: TContext) {
38
+ this.pluginManager = new PluginManager({ events: this.eventBus });
39
+ }
37
40
 
38
41
  /**
39
42
  * Subscribe to an event.
@@ -56,17 +59,25 @@ export class TaskRunner<TContext> {
56
59
  event: K,
57
60
  callback: RunnerEventListener<TContext, K>
58
61
  ): void {
59
- /* v8 ignore next 1 */
60
62
  this.eventBus.off(event, callback);
61
63
  }
62
64
 
65
+ /**
66
+ * Registers a plugin.
67
+ * @param plugin The plugin to register.
68
+ * @returns The TaskRunner instance for chaining.
69
+ */
70
+ public use(plugin: Plugin<TContext>): this {
71
+ this.pluginManager.use(plugin);
72
+ return this;
73
+ }
74
+
63
75
  /**
64
76
  * Sets the execution strategy to be used.
65
77
  * @param strategy The execution strategy.
66
78
  * @returns The TaskRunner instance for chaining.
67
79
  */
68
80
  public setExecutionStrategy(strategy: IExecutionStrategy<TContext>): this {
69
- /* v8 ignore next 2 */
70
81
  this.executionStrategy = strategy;
71
82
  return this;
72
83
  }
@@ -77,14 +88,15 @@ export class TaskRunner<TContext> {
77
88
  * @returns A string containing the Mermaid graph definition.
78
89
  */
79
90
  public static getMermaidGraph<T>(steps: TaskStep<T>[]): string {
80
- const graphLines = ["graph TD"];
91
+ const graphLines = new Set<string>(["graph TD"]);
81
92
  const idMap = new Map<string, string>();
82
93
  const usedIds = new Set<string>();
83
94
  const baseIdCounters = new Map<string, number>();
84
95
 
85
96
  const getUniqueId = (name: string) => {
86
- if (idMap.has(name)) {
87
- return idMap.get(name)!;
97
+ const existingId = idMap.get(name);
98
+ if (existingId !== undefined) {
99
+ return existingId;
88
100
  }
89
101
 
90
102
  const sanitized = this.sanitizeMermaidId(name);
@@ -112,17 +124,15 @@ export class TaskRunner<TContext> {
112
124
  return uniqueId;
113
125
  };
114
126
 
115
- // Pre-calculate IDs for all steps to ensure stable generation order
116
- // We sort steps by name to ensure deterministic ID generation regardless of input order if names clash
117
- // But input order is usually significant in graph definition, so we'll stick to input order.
118
- // However, we must process all step NAMES first.
119
- for (const step of steps) {
120
- getUniqueId(step.name);
121
- }
122
-
127
+ // We process nodes in input order to ensure deterministic ID generation
128
+ // (getUniqueId is called for each unique step name in order, populating the cache).
129
+ const processedNamesForNodes = new Set<string>();
123
130
  for (const step of steps) {
124
- const stepId = getUniqueId(step.name);
125
- graphLines.push(` ${stepId}[${JSON.stringify(step.name)}]`);
131
+ if (!processedNamesForNodes.has(step.name)) {
132
+ const stepId = getUniqueId(step.name);
133
+ graphLines.add(` ${stepId}[${JSON.stringify(step.name)}]`);
134
+ processedNamesForNodes.add(step.name);
135
+ }
126
136
  }
127
137
 
128
138
  for (const step of steps) {
@@ -130,12 +140,12 @@ export class TaskRunner<TContext> {
130
140
  const stepId = getUniqueId(step.name);
131
141
  for (const dep of step.dependencies) {
132
142
  const depId = getUniqueId(dep);
133
- graphLines.push(` ${depId} --> ${stepId}`);
143
+ graphLines.add(` ${depId} --> ${stepId}`);
134
144
  }
135
145
  }
136
146
  }
137
147
 
138
- return [...new Set(graphLines)].join("\n");
148
+ return [...graphLines].join("\n");
139
149
  }
140
150
 
141
151
  /**
@@ -159,6 +169,9 @@ export class TaskRunner<TContext> {
159
169
  steps: TaskStep<TContext>[],
160
170
  config?: TaskRunnerExecutionConfig
161
171
  ): Promise<Map<string, TaskResult>> {
172
+ // Initialize plugins
173
+ await this.pluginManager.initialize();
174
+
162
175
  // Validate the task graph before execution
163
176
  const taskGraph: TaskGraph = {
164
177
  tasks: steps.map((step) => ({
@@ -197,9 +210,9 @@ export class TaskRunner<TContext> {
197
210
  config.timeout,
198
211
  config.signal
199
212
  );
200
- } else {
201
- return executor.execute(steps, config?.signal);
202
213
  }
214
+
215
+ return executor.execute(steps, config?.signal);
203
216
  }
204
217
 
205
218
  /**
@@ -39,10 +39,12 @@ export class TaskStateManager<TContext> {
39
39
  this.readyQueue.push(step);
40
40
  } else {
41
41
  for (const dep of deps) {
42
- if (!this.dependencyGraph.has(dep)) {
43
- this.dependencyGraph.set(dep, []);
42
+ let dependents = this.dependencyGraph.get(dep);
43
+ if (dependents === undefined) {
44
+ dependents = [];
45
+ this.dependencyGraph.set(dep, dependents);
44
46
  }
45
- this.dependencyGraph.get(dep)!.push(step);
47
+ dependents.push(step);
46
48
  }
47
49
  }
48
50
  }
@@ -188,11 +190,13 @@ export class TaskStateManager<TContext> {
188
190
  */
189
191
  private cascadeFailure(failedStepName: string): void {
190
192
  const queue = [failedStepName];
193
+ // Optimization: Use index pointer instead of shift() to avoid O(N) array re-indexing
194
+ let head = 0;
191
195
  // Use a set to track visited nodes in this cascade pass to avoid redundant processing,
192
196
  // although checking results.has() in internalMarkSkipped also prevents it.
193
197
 
194
- while (queue.length > 0) {
195
- const currentName = queue.shift()!;
198
+ while (head < queue.length) {
199
+ const currentName = queue[head++];
196
200
  const dependents = this.dependencyGraph.get(currentName);
197
201
 
198
202
  if (!dependents) continue;
@@ -63,8 +63,22 @@ export class WorkflowExecutor<TContext> {
63
63
  }
64
64
 
65
65
  try {
66
+ // Create a signal mechanism to wait for any task completion
67
+ let signalResolver!: () => void;
68
+ let signalPromise = new Promise<void>((resolve) => {
69
+ signalResolver = resolve;
70
+ });
71
+
72
+ const signalWork = () => {
73
+ signalResolver();
74
+ // Reset the promise for the next wait
75
+ signalPromise = new Promise<void>((resolve) => {
76
+ signalResolver = resolve;
77
+ });
78
+ };
79
+
66
80
  // Initial pass
67
- this.processLoop(executingPromises, signal);
81
+ this.processLoop(executingPromises, signalWork, signal);
68
82
 
69
83
  while (
70
84
  this.stateManager.hasPendingTasks() ||
@@ -78,16 +92,13 @@ export class WorkflowExecutor<TContext> {
78
92
  break;
79
93
  } else {
80
94
  // Wait for the next task to finish
81
- await Promise.race(executingPromises);
95
+ await signalPromise;
82
96
  }
83
97
 
84
98
  if (signal?.aborted) {
85
99
  this.stateManager.cancelAllPending(
86
100
  ExecutionConstants.WORKFLOW_CANCELLED
87
101
  );
88
- } else {
89
- // After a task finishes, check for new work
90
- this.processLoop(executingPromises, signal);
91
102
  }
92
103
  }
93
104
 
@@ -109,6 +120,7 @@ export class WorkflowExecutor<TContext> {
109
120
  */
110
121
  private processLoop(
111
122
  executingPromises: Set<Promise<void>>,
123
+ signalWork: () => void,
112
124
  signal?: AbortSignal
113
125
  ): void {
114
126
  const newlyReady = this.stateManager.processDependencies();
@@ -129,12 +141,13 @@ export class WorkflowExecutor<TContext> {
129
141
 
130
142
  const step = this.readyQueue.pop()!;
131
143
 
132
- const taskPromise = this.executeTaskStep(step, signal)
133
- .finally(() => {
134
- executingPromises.delete(taskPromise);
135
- // When a task finishes, we try to run more
136
- this.processLoop(executingPromises, signal);
137
- });
144
+ const taskPromise = this.executeTaskStep(step, signal).finally(() => {
145
+ executingPromises.delete(taskPromise);
146
+ // When a task finishes, we try to run more
147
+ this.processLoop(executingPromises, signalWork, signal);
148
+ // Signal that a task has completed
149
+ signalWork();
150
+ });
138
151
 
139
152
  executingPromises.add(taskPromise);
140
153
  }
@@ -0,0 +1,32 @@
1
+ import { EventBus } from "../EventBus.js";
2
+
3
+ /**
4
+ * Context provided to a plugin during installation.
5
+ * exposes capabilities to hook into the runner lifecycle and context.
6
+ */
7
+ export interface PluginContext<TContext> {
8
+ /**
9
+ * The event bus instance to subscribe to runner events.
10
+ */
11
+ events: EventBus<TContext>;
12
+ }
13
+
14
+ /**
15
+ * Interface that all plugins must implement.
16
+ */
17
+ export interface Plugin<TContext> {
18
+ /**
19
+ * Unique name of the plugin.
20
+ */
21
+ name: string;
22
+ /**
23
+ * Semantic version of the plugin.
24
+ */
25
+ version: string;
26
+ /**
27
+ * Called when the plugin is installed into the runner.
28
+ * This is where the plugin should subscribe to events or perform setup.
29
+ * @param context The plugin context exposing runner capabilities.
30
+ */
31
+ install(context: PluginContext<TContext>): void | Promise<void>;
32
+ }