@calmo/task-runner 3.4.0 → 3.4.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/.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 +8 -2
- package/CHANGELOG.md +178 -174
- package/README.md +23 -23
- package/coverage/coverage-final.json +8 -8
- package/coverage/index.html +7 -7
- package/coverage/lcov-report/index.html +7 -7
- package/coverage/lcov-report/src/EventBus.ts.html +27 -21
- 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 +1 -1
- package/coverage/lcov-report/src/WorkflowExecutor.ts.html +21 -12
- 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 +8 -8
- 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 +5 -5
- package/coverage/lcov-report/src/strategies/index.html +1 -1
- package/coverage/lcov.info +266 -262
- package/coverage/src/EventBus.ts.html +27 -21
- 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 +1 -1
- package/coverage/src/WorkflowExecutor.ts.html +21 -12
- package/coverage/src/contracts/RunnerEvents.ts.html +1 -1
- package/coverage/src/contracts/index.html +1 -1
- package/coverage/src/index.html +8 -8
- 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 +5 -5
- 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/WorkflowExecutor.js +2 -1
- 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-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 +1 -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/WorkflowExecutor.ts +13 -10
- 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 +108 -108
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Change: Add Comprehensive Integration Tests
|
|
2
2
|
|
|
3
3
|
## Why
|
|
4
|
+
|
|
4
5
|
The current test suite relies heavily on unit tests and mocks. To ensure robust behavior in real-world scenarios, we need comprehensive integration tests that execute full task graphs without mocks, validating complex configurations and interactions.
|
|
5
6
|
|
|
6
7
|
## What Changes
|
|
8
|
+
|
|
7
9
|
- Create a dedicated `tests/integration-tests/` directory.
|
|
8
10
|
- Implement 10-20 integration test scenarios covering:
|
|
9
11
|
- Linear and branching dependencies.
|
|
@@ -14,5 +16,6 @@ The current test suite relies heavily on unit tests and mocks. To ensure robust
|
|
|
14
16
|
- Error recovery (if retry policy is implemented, otherwise standard error states).
|
|
15
17
|
|
|
16
18
|
## Impact
|
|
19
|
+
|
|
17
20
|
- Affected specs: `task-runner` (no functional changes to the runtime, but validates existing specs)
|
|
18
21
|
- Affected code: `tests/integration-tests/*`
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Change: Add Task Retry Policy
|
|
2
2
|
|
|
3
3
|
## Why
|
|
4
|
+
|
|
4
5
|
Tasks currently run once and fail immediately if they throw an error or return a failure status. Network glitches or transient issues can therefore cause an entire workflow to fail unnecessarily.
|
|
5
6
|
|
|
6
7
|
## What Changes
|
|
8
|
+
|
|
7
9
|
- Add `TaskRetryConfig` interface to define retry behavior (attempts, delay, backoff).
|
|
8
10
|
- Update `TaskStep` interface to include optional `retry: TaskRetryConfig`.
|
|
9
11
|
- Implement `RetryingExecutionStrategy` which decorates any `IExecutionStrategy`.
|
|
@@ -12,5 +14,6 @@ Tasks currently run once and fail immediately if they throw an error or return a
|
|
|
12
14
|
- It waits and re-executes the step if applicable.
|
|
13
15
|
|
|
14
16
|
## Impact
|
|
17
|
+
|
|
15
18
|
- **New Components**: `RetryingExecutionStrategy`, `TaskRetryConfig`
|
|
16
19
|
- **Affected Components**: `TaskStep` (interface update)
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# Change: Add Workflow Preview
|
|
2
2
|
|
|
3
3
|
## Why
|
|
4
|
+
|
|
4
5
|
It can be difficult to understand the execution flow of complex dependency graphs just by looking at the code. Users also currently cannot easily verify the execution plan without running the side effects, which carries risk.
|
|
5
6
|
|
|
6
7
|
## What Changes
|
|
8
|
+
|
|
7
9
|
- Add a `DryRunExecutionStrategy` which implements `IExecutionStrategy`. This allows `WorkflowExecutor` to simulate execution without side effects.
|
|
8
10
|
- Add a standalone utility `generateMermaidGraph(steps: TaskStep[])` to generate a Mermaid.js diagram of the dependency graph.
|
|
9
11
|
- Expose these features via the main `TaskRunner` facade if applicable, or as separate utilities.
|
|
10
12
|
|
|
11
13
|
## Impact
|
|
14
|
+
|
|
12
15
|
- **New Components**: `DryRunExecutionStrategy`, `generateMermaidGraph`
|
|
13
16
|
- **Affected Components**: `WorkflowExecutor` (indirectly, via strategy injection)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
## Implementation
|
|
2
|
+
|
|
2
3
|
- [x] 1.1 Update `TaskRunnerExecutionConfig` to include an optional `dryRun: boolean` property.
|
|
3
4
|
- [x] 1.2 Implement `dryRun` logic in `WorkflowExecutor` (traverse graph, validate order, skip `step.run()`, return `skipped` or `success` pseudo-status).
|
|
4
5
|
- [x] 1.3 Implement `getMermaidGraph(steps: TaskStep[])` method (can be static or instance method on `TaskRunner`).
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# Change: Refactor Core Architecture
|
|
2
2
|
|
|
3
3
|
## Why
|
|
4
|
+
|
|
4
5
|
When multiple developers work on the project, conflicts arise due to tight coupling and poor separation of concerns. Large classes like `WorkflowExecutor` are taking on too many responsibilities, and there is duplicated logic around graph traversal and state management.
|
|
5
6
|
|
|
6
7
|
## What Changes
|
|
8
|
+
|
|
7
9
|
- Decouple `WorkflowExecutor` from `EventBus` (pass a listener interface or use a mediating controller).
|
|
8
10
|
- Extract `TaskExecutionStrategy` to allow pluggable execution modes (e.g., standard, dry-run, debug).
|
|
9
11
|
- Centralize state management for task results and context, moving it out of the executor loop.
|
|
10
12
|
- Standardize error handling and logging (QoL improvements).
|
|
11
13
|
|
|
12
14
|
## Impact
|
|
15
|
+
|
|
13
16
|
- Affected specs: `task-runner` (no behavior change, but structural refactor)
|
|
14
17
|
- Affected code: `src/WorkflowExecutor.ts`, `src/TaskRunner.ts`, new files for extracted logic.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
## Implementation
|
|
2
|
+
|
|
2
3
|
- [x] 1.1 Extract `TaskStateManager` to handle `TaskResult` storage, updates, and context mutations.
|
|
3
4
|
- [x] 1.2 Define `IExecutionStrategy` interface for running tasks (Strategy Pattern).
|
|
4
5
|
- [x] 1.3 Refactor `WorkflowExecutor` to use `TaskStateManager` and `IExecutionStrategy`.
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
# Feature: Per-Task Timeout
|
|
2
2
|
|
|
3
3
|
## 🎯 User Story
|
|
4
|
+
|
|
4
5
|
"As a developer, I want to define a maximum execution time for specific tasks so that a single hung task (e.g., a stalled network request) fails fast without blocking the rest of the independent tasks or waiting for the global workflow timeout."
|
|
5
6
|
|
|
6
7
|
## ❓ Why
|
|
8
|
+
|
|
7
9
|
Currently, the `TaskRunner` allows a **global** timeout for the entire `execute()` call. However, this is insufficient for granular control:
|
|
10
|
+
|
|
8
11
|
1. **Varying Latency**: Some tasks are expected to be fast (local validation), others slow (data fetching). A global timeout of 30s is too loose for the fast ones.
|
|
9
12
|
2. **Boilerplate**: Developers currently have to manually implement `setTimeout`, `Promise.race`, and `AbortController` logic inside every `run()` method to handle timeouts properly.
|
|
10
13
|
3. **Resilience**: A single "zombie" task can hold up the entire pipeline until the global timeout kills everything. Per-task timeouts allow failing that specific task (and skipping its dependents) while letting other independent tasks continue.
|
|
11
14
|
|
|
12
15
|
## 🛠️ What Changes
|
|
16
|
+
|
|
13
17
|
1. **Interface Update**: Update `TaskStep<T>` to accept an optional `timeout` property (in milliseconds).
|
|
14
18
|
2. **Execution Strategy**: Update `StandardExecutionStrategy` to:
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
+
- Create a local timeout timer for the task.
|
|
20
|
+
- Create a combined `AbortSignal` (merging the workflow's signal and the local timeout).
|
|
21
|
+
- Race the task execution against the timer.
|
|
22
|
+
- Return a specific failure result if the timeout wins.
|
|
19
23
|
|
|
20
24
|
## ✅ Acceptance Criteria
|
|
25
|
+
|
|
21
26
|
- [ ] A task with `timeout: 100` must fail if the `run` method takes > 100ms.
|
|
22
27
|
- [ ] The error message for a timed-out task should clearly state "Task timed out after 100ms".
|
|
23
28
|
- [ ] The `AbortSignal` passed to the task's `run` method must be triggered when the timeout occurs.
|
|
24
|
-
- [ ] If the Global Workflow is cancelled
|
|
25
|
-
- [ ] A task completing
|
|
29
|
+
- [ ] If the Global Workflow is cancelled _before_ the task times out, the task should receive the cancellation signal immediately.
|
|
30
|
+
- [ ] A task completing _before_ the timeout should clear the timer to prevent open handles.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- If yes, create an `AbortController`.
|
|
12
12
|
- Set a `setTimeout` to trigger the controller.
|
|
13
13
|
- Use `Promise.race` (or simply pass the new signal and wait) to handle the timeout.
|
|
14
|
-
- **Crucial**: Ensure the new signal respects the
|
|
14
|
+
- **Crucial**: Ensure the new signal respects the _parent_ `signal` (if global cancel happens, local signal must also abort).
|
|
15
15
|
- **Crucial**: Clean up the timer (`clearTimeout`) in a `finally` block.
|
|
16
16
|
|
|
17
17
|
- [ ] **Task 3: Unit Tests**
|
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
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
|
}
|