@calmo/task-runner 1.0.1 → 1.1.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/src/TaskRunner.ts CHANGED
@@ -1,6 +1,48 @@
1
1
  import { TaskStep } from "./TaskStep.js";
2
2
  import { TaskResult } from "./TaskResult.js";
3
3
 
4
+ /**
5
+ * Define the payload for every possible event in the lifecycle.
6
+ */
7
+ export interface RunnerEventPayloads<TContext> {
8
+ workflowStart: {
9
+ context: TContext;
10
+ steps: TaskStep<TContext>[];
11
+ };
12
+ workflowEnd: {
13
+ context: TContext;
14
+ results: Map<string, TaskResult>;
15
+ };
16
+ taskStart: {
17
+ step: TaskStep<TContext>;
18
+ };
19
+ taskEnd: {
20
+ step: TaskStep<TContext>;
21
+ result: TaskResult;
22
+ };
23
+ taskSkipped: {
24
+ step: TaskStep<TContext>;
25
+ result: TaskResult;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * A generic listener type that maps the event key to its specific payload.
31
+ */
32
+ export type RunnerEventListener<
33
+ TContext,
34
+ K extends keyof RunnerEventPayloads<TContext>,
35
+ > = (data: RunnerEventPayloads<TContext>[K]) => void | Promise<void>;
36
+
37
+ /**
38
+ * Helper type for the listeners map to avoid private access issues in generic contexts.
39
+ */
40
+ type ListenerMap<TContext> = {
41
+ [K in keyof RunnerEventPayloads<TContext>]?: Set<
42
+ RunnerEventListener<TContext, K>
43
+ >;
44
+ };
45
+
4
46
  /**
5
47
  * The main class that orchestrates the execution of a list of tasks
6
48
  * based on their dependencies, with support for parallel execution.
@@ -8,12 +50,76 @@ import { TaskResult } from "./TaskResult.js";
8
50
  */
9
51
  export class TaskRunner<TContext> {
10
52
  private running = new Set<string>();
53
+ private listeners: ListenerMap<TContext> = {};
11
54
 
12
55
  /**
13
56
  * @param context The shared context object to be passed to each task.
14
57
  */
15
58
  constructor(private context: TContext) {}
16
59
 
60
+ /**
61
+ * Subscribe to an event.
62
+ * @param event The event name.
63
+ * @param callback The callback to execute when the event is emitted.
64
+ */
65
+ public on<K extends keyof RunnerEventPayloads<TContext>>(
66
+ event: K,
67
+ callback: RunnerEventListener<TContext, K>
68
+ ): void {
69
+ if (!this.listeners[event]) {
70
+ // Type assertion needed because TypeScript cannot verify that the generic K
71
+ // matches the specific key in the mapped type during assignment.
72
+ this.listeners[event] = new Set() as unknown as ListenerMap<TContext>[K];
73
+ }
74
+ // Type assertion needed to tell TS that this specific Set matches the callback type
75
+ (this.listeners[event] as Set<RunnerEventListener<TContext, K>>).add(
76
+ callback
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Unsubscribe from an event.
82
+ * @param event The event name.
83
+ * @param callback The callback to remove.
84
+ */
85
+ public off<K extends keyof RunnerEventPayloads<TContext>>(
86
+ event: K,
87
+ callback: RunnerEventListener<TContext, K>
88
+ ): void {
89
+ if (this.listeners[event]) {
90
+ (this.listeners[event] as Set<RunnerEventListener<TContext, K>>).delete(
91
+ callback
92
+ );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Emit an event to all subscribers.
98
+ * @param event The event name.
99
+ * @param data The payload for the event.
100
+ */
101
+ private emit<K extends keyof RunnerEventPayloads<TContext>>(
102
+ event: K,
103
+ data: RunnerEventPayloads<TContext>[K]
104
+ ): void {
105
+ const listeners = this.listeners[event] as
106
+ | Set<RunnerEventListener<TContext, K>>
107
+ | undefined;
108
+ if (listeners) {
109
+ for (const listener of listeners) {
110
+ try {
111
+ listener(data);
112
+ } catch (error) {
113
+ // Prevent listener errors from bubbling up
114
+ console.error(
115
+ `Error in event listener for ${String(event)}:`,
116
+ error
117
+ );
118
+ }
119
+ }
120
+ }
121
+ }
122
+
17
123
  /**
18
124
  * Executes a list of tasks, respecting their dependencies and running
19
125
  * independent tasks in parallel.
@@ -22,6 +128,8 @@ export class TaskRunner<TContext> {
22
128
  * and values are the corresponding TaskResult objects.
23
129
  */
24
130
  async execute(steps: TaskStep<TContext>[]): Promise<Map<string, TaskResult>> {
131
+ this.emit("workflowStart", { context: this.context, steps });
132
+
25
133
  const results = new Map<string, TaskResult>();
26
134
 
27
135
  while (results.size < steps.length) {
@@ -44,10 +152,12 @@ export class TaskRunner<TContext> {
44
152
  (dep) => results.has(dep) && results.get(dep)?.status !== "success"
45
153
  );
46
154
  if (failedDep) {
47
- results.set(step.name, {
155
+ const result: TaskResult = {
48
156
  status: "skipped",
49
157
  message: `Skipped due to failed dependency: ${failedDep}`,
50
- });
158
+ };
159
+ results.set(step.name, result);
160
+ this.emit("taskSkipped", { step, result });
51
161
  }
52
162
  }
53
163
 
@@ -66,6 +176,7 @@ export class TaskRunner<TContext> {
66
176
  await Promise.all(
67
177
  readySteps.map(async (step) => {
68
178
  this.running.add(step.name);
179
+ this.emit("taskStart", { step });
69
180
  try {
70
181
  const result = await step.run(this.context);
71
182
  results.set(step.name, result);
@@ -76,11 +187,14 @@ export class TaskRunner<TContext> {
76
187
  });
77
188
  } finally {
78
189
  this.running.delete(step.name);
190
+ const result = results.get(step.name)!;
191
+ this.emit("taskEnd", { step, result });
79
192
  }
80
193
  })
81
194
  );
82
195
  }
83
196
 
197
+ this.emit("workflowEnd", { context: this.context, results });
84
198
  return results;
85
199
  }
86
200
  }
@@ -0,0 +1,59 @@
1
+ <?xml version="1.0" encoding="UTF-8" ?>
2
+ <testsuites name="vitest tests" tests="19" failures="0" errors="0" time="0.135083259">
3
+ <testsuite name="tests/ComplexScenario.test.ts" timestamp="2026-01-18T03:50:46.197Z" hostname="runnervmmtnos" tests="2" failures="0" errors="0" skipped="0" time="0.009883356">
4
+ <testcase classname="tests/ComplexScenario.test.ts" name="Complex Scenario Integration Tests &gt; 1. All steps execute when all succeed and context is hydrated" time="0.006132912">
5
+ <system-out>
6
+ Running StepA
7
+ Running StepB
8
+ Running StepC
9
+
10
+ Running StepD
11
+ Running StepF
12
+
13
+ Running StepE
14
+ Running StepG
15
+
16
+ </system-out>
17
+ </testcase>
18
+ <testcase classname="tests/ComplexScenario.test.ts" name="Complex Scenario Integration Tests &gt; 2. The &apos;skip&apos; propagation works as intended if something breaks up in the tree" time="0.00153012">
19
+ </testcase>
20
+ </testsuite>
21
+ <testsuite name="tests/TaskRunner.test.ts" timestamp="2026-01-18T03:50:46.199Z" hostname="runnervmmtnos" tests="10" failures="0" errors="0" skipped="0" time="0.109323687">
22
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should run tasks in the correct sequential order" time="0.002455516">
23
+ </testcase>
24
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should handle an empty list of tasks gracefully" time="0.000305152">
25
+ </testcase>
26
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should run independent tasks in parallel" time="0.100731975">
27
+ </testcase>
28
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should skip dependent tasks if a root task fails" time="0.00046882">
29
+ </testcase>
30
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should throw an error for &apos;circular dependency&apos;" time="0.00185594">
31
+ </testcase>
32
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should throw an error for &apos;missing dependency&apos;" time="0.000228108">
33
+ </testcase>
34
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should handle tasks that throw an error during execution" time="0.000243576">
35
+ </testcase>
36
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should skip tasks whose dependencies are skipped" time="0.000262202">
37
+ </testcase>
38
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should handle tasks that throw a non-Error object during execution" time="0.000268865">
39
+ </testcase>
40
+ <testcase classname="tests/TaskRunner.test.ts" name="TaskRunner &gt; should handle duplicate steps where one gets skipped due to failed dependency" time="0.00040515">
41
+ </testcase>
42
+ </testsuite>
43
+ <testsuite name="tests/TaskRunnerEvents.test.ts" timestamp="2026-01-18T03:50:46.201Z" hostname="runnervmmtnos" tests="7" failures="0" errors="0" skipped="0" time="0.015876216">
44
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should fire all lifecycle events in a successful run" time="0.004415742">
45
+ </testcase>
46
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should fire taskSkipped event when dependency fails" time="0.005824924">
47
+ </testcase>
48
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should not crash if a listener throws an error" time="0.001840071">
49
+ </testcase>
50
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should fire workflow events even for empty step list" time="0.000423804">
51
+ </testcase>
52
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should handle unsubscribe correctly" time="0.000460765">
53
+ </testcase>
54
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should safely handle off() when no listeners exist" time="0.000210355">
55
+ </testcase>
56
+ <testcase classname="tests/TaskRunnerEvents.test.ts" name="TaskRunner Events &gt; should support multiple listeners for the same event" time="0.000482585">
57
+ </testcase>
58
+ </testsuite>
59
+ </testsuites>
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "rootDir": "."
6
+ },
7
+ "include": ["src/**/*", "tests/**/*"]
8
+ }