@calmo/task-runner 4.0.4 → 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 (91) hide show
  1. package/.github/workflows/codeql.yml +1 -1
  2. package/.github/workflows/release-please.yml +2 -2
  3. package/.jules/nexus.md +10 -0
  4. package/.release-please-manifest.json +1 -1
  5. package/AGENTS.md +3 -0
  6. package/CHANGELOG.md +46 -0
  7. package/CODE_OF_CONDUCT.md +131 -0
  8. package/CONTRIBUTING.md +89 -0
  9. package/README.md +34 -0
  10. package/conductor/code_styleguides/general.md +23 -0
  11. package/conductor/code_styleguides/javascript.md +51 -0
  12. package/conductor/code_styleguides/typescript.md +43 -0
  13. package/conductor/product-guidelines.md +14 -0
  14. package/conductor/product.md +16 -0
  15. package/conductor/setup_state.json +1 -0
  16. package/conductor/tech-stack.md +19 -0
  17. package/conductor/workflow.md +334 -0
  18. package/dist/EventBus.js +16 -16
  19. package/dist/EventBus.js.map +1 -1
  20. package/dist/PluginManager.d.ts +22 -0
  21. package/dist/PluginManager.js +39 -0
  22. package/dist/PluginManager.js.map +1 -0
  23. package/dist/TaskGraphValidator.d.ts +1 -1
  24. package/dist/TaskGraphValidator.js +16 -21
  25. package/dist/TaskGraphValidator.js.map +1 -1
  26. package/dist/TaskResult.d.ts +9 -0
  27. package/dist/TaskRunner.d.ts +8 -1
  28. package/dist/TaskRunner.js +64 -41
  29. package/dist/TaskRunner.js.map +1 -1
  30. package/dist/TaskStateManager.d.ts +22 -6
  31. package/dist/TaskStateManager.js +105 -45
  32. package/dist/TaskStateManager.js.map +1 -1
  33. package/dist/WorkflowExecutor.js +36 -20
  34. package/dist/WorkflowExecutor.js.map +1 -1
  35. package/dist/contracts/Plugin.d.ts +30 -0
  36. package/dist/contracts/Plugin.js +2 -0
  37. package/dist/contracts/Plugin.js.map +1 -0
  38. package/dist/strategies/DryRunExecutionStrategy.d.ts +1 -1
  39. package/dist/strategies/DryRunExecutionStrategy.js +2 -4
  40. package/dist/strategies/DryRunExecutionStrategy.js.map +1 -1
  41. package/dist/utils/PriorityQueue.d.ts +13 -0
  42. package/dist/utils/PriorityQueue.js +82 -0
  43. package/dist/utils/PriorityQueue.js.map +1 -0
  44. package/openspec/changes/add-middleware-support/proposal.md +19 -0
  45. package/openspec/changes/add-middleware-support/specs/task-runner/spec.md +34 -0
  46. package/openspec/changes/add-middleware-support/tasks.md +9 -0
  47. package/openspec/changes/add-resource-concurrency/proposal.md +18 -0
  48. package/openspec/changes/add-resource-concurrency/specs/task-runner/spec.md +25 -0
  49. package/openspec/changes/add-resource-concurrency/tasks.md +10 -0
  50. package/openspec/changes/allow-plugin-hooks/design.md +51 -0
  51. package/openspec/changes/allow-plugin-hooks/proposal.md +12 -0
  52. package/openspec/changes/allow-plugin-hooks/specs/post-task/spec.md +21 -0
  53. package/openspec/changes/allow-plugin-hooks/specs/pre-task/spec.md +32 -0
  54. package/openspec/changes/allow-plugin-hooks/tasks.md +7 -0
  55. package/openspec/changes/{feat-task-metrics → archive/2026-01-22-feat-task-metrics}/proposal.md +1 -1
  56. package/openspec/changes/archive/2026-01-22-feat-task-metrics/tasks.md +6 -0
  57. package/openspec/changes/archive/2026-02-15-implement-plugin-system/design.md +45 -0
  58. package/openspec/changes/archive/2026-02-15-implement-plugin-system/proposal.md +13 -0
  59. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-context/spec.md +17 -0
  60. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-loading/spec.md +27 -0
  61. package/openspec/changes/archive/2026-02-15-implement-plugin-system/tasks.md +7 -0
  62. package/openspec/changes/feat-completion-dependencies/proposal.md +58 -0
  63. package/openspec/changes/feat-completion-dependencies/tasks.md +46 -0
  64. package/openspec/changes/feat-conditional-retries/proposal.md +18 -0
  65. package/openspec/changes/feat-conditional-retries/specs/task-runner/spec.md +23 -0
  66. package/openspec/changes/feat-conditional-retries/tasks.md +37 -0
  67. package/openspec/changes/feat-state-persistence/specs/task-runner/spec.md +47 -0
  68. package/openspec/changes/feat-task-loop/proposal.md +22 -0
  69. package/openspec/changes/feat-task-loop/specs/task-runner/spec.md +34 -0
  70. package/openspec/changes/feat-task-loop/tasks.md +8 -0
  71. package/openspec/specs/plugin-context/spec.md +19 -0
  72. package/openspec/specs/plugin-loading/spec.md +29 -0
  73. package/openspec/specs/release-pr/spec.md +31 -0
  74. package/openspec/specs/task-runner/spec.md +12 -0
  75. package/package.json +1 -1
  76. package/src/EventBus.ts +7 -8
  77. package/src/PluginManager.ts +41 -0
  78. package/src/TaskGraphValidator.ts +22 -24
  79. package/src/TaskResult.ts +9 -0
  80. package/src/TaskRunner.ts +78 -46
  81. package/src/TaskStateManager.ts +118 -46
  82. package/src/WorkflowExecutor.ts +45 -22
  83. package/src/contracts/Plugin.ts +32 -0
  84. package/src/strategies/DryRunExecutionStrategy.ts +2 -3
  85. package/src/utils/PriorityQueue.ts +101 -0
  86. package/openspec/changes/feat-task-metrics/tasks.md +0 -6
  87. /package/openspec/changes/{adopt-release-pr → archive/2026-01-22-adopt-release-pr}/design.md +0 -0
  88. /package/openspec/changes/{adopt-release-pr → archive/2026-01-22-adopt-release-pr}/proposal.md +0 -0
  89. /package/openspec/changes/{adopt-release-pr → archive/2026-01-22-adopt-release-pr}/specs/release-pr/spec.md +0 -0
  90. /package/openspec/changes/{adopt-release-pr → archive/2026-01-22-adopt-release-pr}/tasks.md +0 -0
  91. /package/openspec/changes/{feat-task-metrics → archive/2026-01-22-feat-task-metrics}/specs/001-generic-task-runner/spec.md +0 -0
@@ -0,0 +1,23 @@
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: Conditional Retries
4
+
5
+ The system SHALL support conditional retries where a user-defined predicate determines if a task failure warrants a retry attempt.
6
+
7
+ #### Scenario: Retry allowed by predicate
8
+ - **GIVEN** a task configured with retries and a `shouldRetry` predicate
9
+ - **WHEN** the task fails with an error
10
+ - **AND** the `shouldRetry` predicate returns `true` for that error
11
+ - **THEN** the system SHALL proceed with the retry logic (respecting attempt limits and delays).
12
+
13
+ #### Scenario: Retry denied by predicate
14
+ - **GIVEN** a task configured with retries and a `shouldRetry` predicate
15
+ - **WHEN** the task fails with an error
16
+ - **AND** the `shouldRetry` predicate returns `false` for that error
17
+ - **THEN** the system SHALL NOT retry the task.
18
+ - **AND** the task status SHALL be immediately marked as 'failure'.
19
+
20
+ #### Scenario: Default retry behavior
21
+ - **GIVEN** a task configured with retries but NO `shouldRetry` predicate
22
+ - **WHEN** the task fails with an error
23
+ - **THEN** the system SHALL retry the task (respecting attempt limits and delays), preserving backward compatibility.
@@ -0,0 +1,37 @@
1
+ # Engineering Tasks
2
+
3
+ - [ ] **Task 1: Update TaskRetryConfig Interface**
4
+ - Modify `src/contracts/TaskRetryConfig.ts`.
5
+ - Add `shouldRetry?: (error: unknown) => boolean;` to the `TaskRetryConfig` interface.
6
+ - Document the property with JSDoc explaining its purpose (return true to retry, false to abort).
7
+
8
+ - [ ] **Task 2: Update RetryingExecutionStrategy Logic**
9
+ - Modify `src/strategies/RetryingExecutionStrategy.ts`.
10
+ - Inside the `execute` loop, after receiving a "failure" result:
11
+ - Check if `config.shouldRetry` is defined.
12
+ - If defined, call it with `result.error`.
13
+ - If it returns `false`, break the loop and return the failure result immediately.
14
+ - If it returns `true` (or is undefined), proceed with the existing retry logic (check attempts, delay).
15
+
16
+ - [ ] **Task 3: Unit Tests**
17
+ - Create `tests/strategies/RetryingExecutionStrategy.conditional.test.ts` (or add to existing test file if small).
18
+ - **Scenario 1**: `shouldRetry` returns `true`.
19
+ - Setup a task that fails 2 times then succeeds.
20
+ - Configure `shouldRetry: () => true`.
21
+ - Verify it retries and eventually succeeds.
22
+ - **Scenario 2**: `shouldRetry` returns `false`.
23
+ - Setup a task that fails with a specific error "FatalError".
24
+ - Configure `shouldRetry: (err) => err !== "FatalError"`.
25
+ - Verify it does *not* retry and returns failure immediately after the first attempt.
26
+ - **Scenario 3**: `shouldRetry` is undefined (Legacy behavior).
27
+ - Setup a failing task.
28
+ - Verify it retries up to max attempts.
29
+
30
+ - [ ] **Task 4: Integration Test**
31
+ - Create `tests/integration-tests/conditional-retries.test.ts`.
32
+ - Define a workflow with two tasks:
33
+ - Task A: Fails with "Transient" error (retries allowed).
34
+ - Task B: Fails with "Permanent" error (retries blocked by predicate).
35
+ - Execute workflow.
36
+ - Verify Task A consumed its retries (or succeeded).
37
+ - Verify Task B failed immediately (did not consume retries).
@@ -0,0 +1,47 @@
1
+ # Workflow State Persistence Specification
2
+
3
+ ## Purpose
4
+
5
+ Enables the `TaskRunner` to save its execution state and resume from that state later, allowing for recovery from failures or pausing long-running workflows without re-executing completed tasks.
6
+
7
+ ## Requirements
8
+
9
+ ### Requirement: State Snapshot Exposure
10
+
11
+ The system SHALL provide a mechanism to retrieve the current execution state of a workflow.
12
+
13
+ #### Scenario: Retrieving state from TaskRunner
14
+ - **WHEN** a workflow is running or completed
15
+ - **THEN** the `TaskRunner` (or its underlying `TaskStateManager`) SHALL expose a method to retrieve a snapshot of the current state.
16
+ - **AND** the snapshot SHALL contain the `results` of all executed tasks.
17
+ - **AND** the snapshot SHALL be serializable (e.g., to JSON).
18
+
19
+ ### Requirement: Hydrated Initialization
20
+
21
+ The system SHALL allow initializing a `TaskRunner` with a pre-existing state snapshot.
22
+
23
+ #### Scenario: Initializing with a snapshot
24
+ - **GIVEN** a valid state snapshot from a previous execution
25
+ - **WHEN** the `TaskRunner` is built using `TaskRunnerBuilder`
26
+ - **THEN** the builder SHALL accept the snapshot as an initial state.
27
+ - **AND** the `TaskRunner` SHALL start with the internal state reflecting the snapshot (i.e., known task results).
28
+
29
+ ### Requirement: Resumable Execution Logic
30
+
31
+ The `WorkflowExecutor` SHALL respect the initial hydrated state during execution, skipping already completed tasks.
32
+
33
+ #### Scenario: Skipping successful tasks
34
+ - **GIVEN** a `TaskRunner` initialized with a snapshot where Task A is marked as `success`
35
+ - **WHEN** `execute()` is called
36
+ - **THEN** Task A SHALL NOT be executed again.
37
+ - **AND** Task A SHALL be considered completed for the purpose of checking dependencies of downstream tasks.
38
+
39
+ #### Scenario: Re-running non-successful tasks
40
+ - **GIVEN** a `TaskRunner` initialized with a snapshot where Task B is marked as `failure`, `cancelled`, or `skipped`
41
+ - **WHEN** `execute()` is called
42
+ - **THEN** Task B SHOULD be evaluated for execution (subject to dependency checks).
43
+
44
+ #### Scenario: Handling Context
45
+ - **GIVEN** a resumed workflow
46
+ - **THEN** it is the caller's responsibility to provide the necessary `Context` for task execution.
47
+ - **AND** the `TaskRunner` SHALL NOT attempt to automatically restore the context object from the state snapshot (as it may contain non-serializable data).
@@ -0,0 +1,22 @@
1
+ # Change: feat-task-loop
2
+
3
+ ## Why
4
+
5
+ Users often need to orchestrate tasks that involve waiting for a condition to be met, such as polling an API for a deployment status or waiting for a database to be healthy. Currently, this logic must be implemented imperatively within the `run` method of a task, which obscures the intent and mixes orchestration concerns with business logic.
6
+
7
+ ## What Changes
8
+
9
+ - Add a `loop` configuration to the `TaskStep` interface.
10
+ - Introduce a `LoopingExecutionStrategy` that wraps other strategies to handle conditional re-execution.
11
+ - The `loop` configuration will support:
12
+ - `interval`: Time in milliseconds to wait between iterations.
13
+ - `maxIterations`: Maximum number of times to run the task.
14
+ - `until`: A predicate function `(context, result) => boolean` that determines when the loop should stop.
15
+
16
+ ## Impact
17
+
18
+ - **Affected Specs**: `task-runner`
19
+ - **Affected Code**:
20
+ - `src/TaskStep.ts`: Update interface.
21
+ - `src/strategies/LoopingExecutionStrategy.ts`: New file.
22
+ - `src/TaskRunnerBuilder.ts`: Integrate the new strategy.
@@ -0,0 +1,34 @@
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: Task Looping
4
+
5
+ The `TaskStep` interface SHALL support an optional `loop` property of type `TaskLoopConfig` to enable conditional re-execution of a task (polling).
6
+
7
+ #### Scenario: Loop until condition met
8
+
9
+ - **GIVEN** a task with a `loop` configuration
10
+ - **AND** the `loop.until` predicate returns `false`
11
+ - **THEN** the task SHALL re-execute after the specified `interval`.
12
+ - **WHEN** the `loop.until` predicate returns `true`
13
+ - **THEN** the task SHALL complete successfully with the last result.
14
+
15
+ #### Scenario: Max iterations reached
16
+
17
+ - **GIVEN** a task with a `loop` configuration
18
+ - **AND** the task has re-executed `maxIterations` times without the predicate returning `true`
19
+ - **THEN** the task SHALL fail with an error indicating the loop limit was reached.
20
+
21
+ #### Scenario: Integration with Retries
22
+
23
+ - **GIVEN** a task with both `retry` and `loop` configurations
24
+ - **WHEN** the task fails (throws an error)
25
+ - **THEN** the `RetryingExecutionStrategy` SHALL handle the retry logic first.
26
+ - **AND** the loop iteration SHALL only count once the task executes successfully (or fails after retries).
27
+
28
+ #### Scenario: Loop Config Structure
29
+
30
+ - **GIVEN** a `TaskLoopConfig` object
31
+ - **THEN** it SHALL support:
32
+ - `interval`: Time in milliseconds to wait between iterations (default: 0).
33
+ - `maxIterations`: Maximum number of iterations (default: 1).
34
+ - `until`: A predicate function `(context: TContext, result: TaskResult) => boolean`.
@@ -0,0 +1,8 @@
1
+ ## 1. Implementation
2
+
3
+ - [ ] 1.1 Define `TaskLoopConfig` interface in `src/contracts/TaskLoopConfig.ts`.
4
+ - [ ] 1.2 Update `TaskStep` interface in `src/TaskStep.ts` to include optional `loop: TaskLoopConfig`.
5
+ - [ ] 1.3 Implement `LoopingExecutionStrategy` in `src/strategies/LoopingExecutionStrategy.ts`.
6
+ - [ ] 1.4 Update `TaskRunnerBuilder` in `src/TaskRunnerBuilder.ts` to include `LoopingExecutionStrategy`.
7
+ - [ ] 1.5 Add unit tests for `LoopingExecutionStrategy`.
8
+ - [ ] 1.6 Verify integration with `RetryingExecutionStrategy` (looping should happen outside retries).
@@ -0,0 +1,19 @@
1
+ # plugin-context Specification
2
+
3
+ ## Purpose
4
+ TBD - created by archiving change implement-plugin-system. Update Purpose after archive.
5
+ ## Requirements
6
+ ### Requirement: Expose EventBus to plugins
7
+
8
+ The `PluginContext` passed to `install` MUST expose the `EventBus` (or equivalent API) to allow listening to events.
9
+
10
+ #### Scenario: Listening to task events
11
+ Given a plugin is being installed
12
+ When it accesses `context.events`
13
+ Then it can subscribe to `taskStart` and `taskEnd` events
14
+
15
+ #### Scenario: Emitting custom events (Future)
16
+ Given a plugin
17
+ When it has access to the event bus
18
+ Then it should be able to emit events (if allowed by design)
19
+
@@ -0,0 +1,29 @@
1
+ # plugin-loading Specification
2
+
3
+ ## Purpose
4
+ TBD - created by archiving change implement-plugin-system. Update Purpose after archive.
5
+ ## Requirements
6
+ ### Requirement: Support registering plugins
7
+
8
+ The `TaskRunner` MUST allow registering plugins via a `use` method or configuration.
9
+
10
+ #### Scenario: Registering a valid plugin
11
+ Given a `TaskRunner` instance
12
+ When I call `use` with a valid plugin object
13
+ Then the plugin is added to the internal plugin list
14
+
15
+ #### Scenario: Registering an invalid plugin
16
+ Given a `TaskRunner` instance
17
+ When I call `use` with an invalid object (missing install method)
18
+ Then it should throw an error or reject the plugin
19
+
20
+ ### Requirement: Initialize plugins before execution
21
+
22
+ Plugins MUST be initialized (have their `install` method called) before the workflow starts.
23
+
24
+ #### Scenario: Plugin initialization
25
+ Given a registered plugin
26
+ When `execute` is called on the `TaskRunner`
27
+ Then the plugin's `install` method is called with the plugin context
28
+ And the workflow execution proceeds only after `install` completes
29
+
@@ -0,0 +1,31 @@
1
+ # release-pr Specification
2
+
3
+ ## Purpose
4
+ TBD - created by archiving change adopt-release-pr. Update Purpose after archive.
5
+ ## Requirements
6
+ ### Requirement: Release PR Generation
7
+ The system MUST automatically maintain a "Release PR" that targets the `main` branch. This PR must accumulate all conventional changes since the last release, calculating the next semantic version and generating a corresponding changelog entry.
8
+
9
+ #### Scenario: Feature commit triggers PR update
10
+ Given the latest release is `v1.0.0`
11
+ And a developer merges a commit with message `feat: add awesome feature` to `main`
12
+ Then the system should create or update the Release PR
13
+ And the PR title should be `chore: release 1.1.0`
14
+ And the PR body should contain the changelog entry for "add awesome feature"
15
+ And the `package.json` version in the PR should be `1.1.0`
16
+
17
+ #### Scenario: Fix commit triggers patch update
18
+ Given the latest release is `v1.0.0`
19
+ And a developer merges a commit `fix: urgent bug` to `main`
20
+ Then the Release PR should be updated to target version `1.0.1`
21
+
22
+ ### Requirement: Release Publication
23
+ The system MUST execute the release process (git tag, GitHub Release, npm publish) ONLY when the Release PR is merged into `main`.
24
+
25
+ #### Scenario: Merge triggers publish
26
+ Given the Release PR for `v1.1.0` exists
27
+ When a maintainer merges the PR into `main`
28
+ Then the system should create a GitHub Release `v1.1.0`
29
+ And the system should publish the package to the configured registry (NPM)
30
+ And the system should NOT publish any other commits merged to `main` until the next Release PR merge
31
+
@@ -160,3 +160,15 @@ The system SHALL provide a `RetryingExecutionStrategy` that implements `IExecuti
160
160
 
161
161
  - **WHEN** `retry.backoff` is 'exponential'
162
162
  - **THEN** the delay SHALL increase for each attempt (e.g., `delay * 2^attempt`).
163
+
164
+ ### Requirement: Task Execution Metrics
165
+
166
+ The system SHALL record timing metrics for each executed task, including start time, end time, and duration.
167
+
168
+ #### Scenario: Successful execution
169
+ - **WHEN** a task completes successfully
170
+ - **THEN** the task result contains the start timestamp, end timestamp, and duration in milliseconds
171
+
172
+ #### Scenario: Failed execution
173
+ - **WHEN** a task fails
174
+ - **THEN** the task result contains the start timestamp, end timestamp, and duration in milliseconds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calmo/task-runner",
3
- "version": "4.0.4",
3
+ "version": "4.2.0",
4
4
  "description": "A lightweight, type-safe, and domain-agnostic task orchestration engine. It resolves a Directed Acyclic Graph (DAG) of steps, executes independent tasks in parallel, and manages a shared context across the pipeline.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/EventBus.ts CHANGED
@@ -61,11 +61,10 @@ export class EventBus<TContext> {
61
61
  | undefined;
62
62
  if (listeners) {
63
63
  for (const listener of listeners) {
64
- // We use Promise.resolve().then() to schedule the listener on the microtask queue,
64
+ // We use queueMicrotask() to schedule the listener on the microtask queue,
65
65
  // ensuring the emit method remains non-blocking.
66
- // The final .catch() ensures that any errors in the promise infrastructure itself are logged.
67
- Promise.resolve()
68
- .then(() => {
66
+ queueMicrotask(() => {
67
+ try {
69
68
  try {
70
69
  const result = listener(data);
71
70
  if (result instanceof Promise) {
@@ -84,14 +83,14 @@ export class EventBus<TContext> {
84
83
  error
85
84
  );
86
85
  }
87
- })
88
- .catch((error) => {
89
- // detailed handling for the promise chain itself
86
+ } catch (error) {
87
+ // detailed handling for the microtask execution itself
90
88
  console.error(
91
89
  `Unexpected error in event bus execution for ${String(event)}:`,
92
90
  error
93
91
  );
94
- });
92
+ }
93
+ });
95
94
  }
96
95
  }
97
96
  }
@@ -0,0 +1,41 @@
1
+ import { Plugin, PluginContext } from "./contracts/Plugin.js";
2
+
3
+ /**
4
+ * Manages the lifecycle of plugins.
5
+ */
6
+ export class PluginManager<TContext> {
7
+ private plugins: Plugin<TContext>[] = [];
8
+
9
+ constructor(private context: PluginContext<TContext>) {}
10
+
11
+ /**
12
+ * Registers a plugin.
13
+ * @param plugin The plugin to register.
14
+ */
15
+ public use(plugin: Plugin<TContext>): void {
16
+ // Check if plugin is already registered
17
+ if (this.plugins.some((p) => p.name === plugin.name)) {
18
+ // For now, we allow overwriting or just warn?
19
+ // Let's just allow it but maybe log it if we had a logger.
20
+ // Strict check: don't allow duplicate names.
21
+ throw new Error(`Plugin with name '${plugin.name}' is already registered.`);
22
+ }
23
+ this.plugins.push(plugin);
24
+ }
25
+
26
+ /**
27
+ * Initializes all registered plugins.
28
+ */
29
+ public async initialize(): Promise<void> {
30
+ for (const plugin of this.plugins) {
31
+ await plugin.install(this.context);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Returns the list of registered plugins.
37
+ */
38
+ public getPlugins(): ReadonlyArray<Plugin<TContext>> {
39
+ return this.plugins;
40
+ }
41
+ }
@@ -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/TaskResult.ts CHANGED
@@ -12,4 +12,13 @@ export interface TaskResult {
12
12
  error?: string;
13
13
  /** Optional data produced by the step for later inspection. */
14
14
  data?: unknown;
15
+ /** Optional execution metrics for the task. */
16
+ metrics?: {
17
+ /** Start time in milliseconds (performance.now). */
18
+ startTime: number;
19
+ /** End time in milliseconds (performance.now). */
20
+ endTime: number;
21
+ /** Duration in milliseconds. */
22
+ duration: number;
23
+ };
15
24
  }