Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions openspec/specs/task-runner/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,64 @@ The system SHALL record timing metrics for each executed task, including start t
#### Scenario: Failed execution
- **WHEN** a task fails
- **THEN** the task result contains the start timestamp, end timestamp, and duration in milliseconds
## ADDED Requirements

### Requirement: Task Tagging

The `TaskStep` interface SHALL support an optional `tags` array of strings to categorize the step.

#### Scenario: Categorizing a Task
- **GIVEN** a task configuration
- **WHEN** the `tags` property is defined with an array of string identifiers (e.g., `["frontend", "build"]`)
- **THEN** the task SHALL retain those tags for metadata and filtering operations.

### Requirement: Task Filtering Configuration

The system SHALL provide a mechanism to configure execution filters via an interface `TaskFilterConfig`.

#### Scenario: Filter Config Structure
- **GIVEN** a `TaskFilterConfig` object
- **THEN** it SHALL support:
- `includeTags`: Optional array of tag strings. Only tasks with at least one matching tag will be included.
- `excludeTags`: Optional array of tag strings. Tasks with any matching tag will be excluded.
- `includeNames`: Optional array of task names. Only tasks with matching names will be included.
- `excludeNames`: Optional array of task names. Tasks with matching names will be excluded.
- `includeDependencies`: Optional boolean. If true, dependencies of included tasks are automatically included.

#### Scenario: Multiple Inclusion Filters Combine with OR Logic
- **WHEN** multiple `include*` properties are provided (e.g., `includeTags: ['frontend']` and `includeNames: ['lint']`)
- **THEN** a task SHALL be included if it matches **any** of the criteria (OR logic).

### Requirement: Filtering Utility Module

The system SHALL provide a `filterTasks` utility function that accepts an array of `TaskStep`s and a `TaskFilterConfig` and returns a filtered subset of tasks.

#### Scenario: Inclusion Filtering
- **WHEN** `filterTasks` is invoked with `includeTags: ["test"]`
- **THEN** it SHALL return ONLY tasks containing the `"test"` tag.

#### Scenario: Exclusion Filtering
- **WHEN** `filterTasks` is invoked with `excludeNames: ["deploy"]`
- **THEN** it SHALL return all tasks EXCEPT the one named `"deploy"`.

#### Scenario: Dependency Resolution Override
- **WHEN** `filterTasks` is invoked with `includeDependencies: true` and a specific task name is selected
- **THEN** it SHALL return the selected task AND any tasks that the selected task recursively depends on from the original list.

#### Scenario: Exclusion Precedence
- **WHEN** a task is implicitly included because it is a dependency of an explicitly selected task
- **AND** the implicitly included task matches an explicit exclusion criteria (e.g., `excludeTags`)
- **THEN** the explicitly excluded task SHALL NOT be included in the returned array.

#### Scenario: Union of Inclusion Filters
- **WHEN** multiple `include*` criteria are provided
- **THEN** the final set of included tasks SHALL be the union of tasks matching each individual `include*` filter.

### Requirement: Task Execution Filtering Support

The `TaskRunnerExecutionConfig` SHALL support an optional `filter` property of type `TaskFilterConfig`.

#### Scenario: Executing Filtered Tasks
- **WHEN** `TaskRunner.execute` is called with an array of steps and a `filter` in the configuration
- **THEN** the `TaskRunner` SHALL apply the filter to the provided steps.
- **AND** ONLY the filtered subset of steps SHALL be validated and executed.
9 changes: 6 additions & 3 deletions src/TaskRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrateg
import { Plugin } from "./contracts/Plugin.js";
import { PluginManager } from "./PluginManager.js";
import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js";
import { filterTasks } from "./utils/TaskFilter.js";

const MERMAID_ID_REGEX = /[^a-zA-Z0-9_-]/g;

Expand Down Expand Up @@ -176,9 +177,11 @@ export class TaskRunner<TContext> {
// Initialize plugins
await this.pluginManager.initialize();

const filteredSteps = config?.filter ? filterTasks(steps, config.filter) : steps;

// Validate the task graph before execution
const taskGraph: TaskGraph = {
tasks: steps.map((step) => ({
tasks: filteredSteps.map((step) => ({
id: step.name,
dependencies: step.dependencies ?? [],
})),
Expand Down Expand Up @@ -210,13 +213,13 @@ export class TaskRunner<TContext> {
if (config?.timeout !== undefined) {
return this.executeWithTimeout(
executor,
steps,
filteredSteps,
config.timeout,
config.signal
);
}

return executor.execute(steps, config?.signal);
return executor.execute(filteredSteps, config?.signal);
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/TaskRunnerExecutionConfig.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js";

/**
* Configuration options for TaskRunner execution.
*/
Expand All @@ -20,4 +22,8 @@ export interface TaskRunnerExecutionConfig {
* If undefined, all ready tasks will be run in parallel.
*/
concurrency?: number;
/**
* Optional configuration to filter the tasks to execute.
*/
filter?: TaskFilterConfig;
}
2 changes: 2 additions & 0 deletions src/TaskStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface TaskStep<TContext> {
name: string;
/** An optional list of task names that must complete successfully before this step can run. */
dependencies?: string[];
/** Optional list of tags associated with the task for filtering. */
tags?: string[];
/** Optional retry configuration for the task. */
retry?: TaskRetryConfig;
/** Optional loop configuration for the task. */
Expand Down
26 changes: 26 additions & 0 deletions src/contracts/TaskFilterConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Configuration options for filtering tasks before execution.
*/
export interface TaskFilterConfig {
/**
* Only include tasks that match any of these tags.
*/
includeTags?: string[];
/**
* Exclude tasks that match any of these tags, overriding inclusions.
*/
excludeTags?: string[];
/**
* Only include tasks with these specific names.
*/
includeNames?: string[];
/**
* Exclude tasks with these specific names, overriding inclusions.
*/
excludeNames?: string[];
/**
* If true (default), automatically include all necessary dependencies
* of the selected tasks, unless those dependencies are explicitly excluded.
*/
includeDependencies?: boolean;
}
123 changes: 123 additions & 0 deletions src/utils/TaskFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { TaskStep } from "../TaskStep.js";
import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js";

/**
* Filters an array of TaskStep objects based on the provided configuration.
*
* @param steps The original list of tasks.
* @param config The filter configuration.
* @returns A new array of filtered tasks.
*/
export function filterTasks<TContext>(

Check failure on line 11 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 65 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oML&open=AZ1Q4YKa1_32ZbbI5oML&pullRequest=250
steps: TaskStep<TContext>[],
config: TaskFilterConfig
): TaskStep<TContext>[] {
const {
includeTags,
excludeTags,
includeNames,
excludeNames,
includeDependencies = true,
} = config;

// Helper sets for quick lookup
const inclTagsSet = includeTags ? new Set(includeTags) : null;
const exclTagsSet = excludeTags ? new Set(excludeTags) : null;
const inclNamesSet = includeNames ? new Set(includeNames) : null;
const exclNamesSet = excludeNames ? new Set(excludeNames) : null;

// Map of task names to task objects for quick dependency lookup
const taskMap = new Map<string, TaskStep<TContext>>();
for (let i = 0; i < steps.length; i++) {
taskMap.set(steps[i].name, steps[i]);
}

Check warning on line 33 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMM&open=AZ1Q4YKa1_32ZbbI5oMM&pullRequest=250

// 1. Determine initially included tasks
const includedTasks = new Set<string>();

for (let i = 0; i < steps.length; i++) {
const task = steps[i];
let isIncluded = false;

// If no inclusion criteria provided, include all initially
if (!inclTagsSet && !inclNamesSet) {
isIncluded = true;
} else {
// Check if it matches includeNames
if (inclNamesSet && inclNamesSet.has(task.name)) {

Check warning on line 47 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMO&open=AZ1Q4YKa1_32ZbbI5oMO&pullRequest=250
isIncluded = true;
}
// Check if it matches includeTags
if (!isIncluded && inclTagsSet && task.tags) {
for (let j = 0; j < task.tags.length; j++) {
if (inclTagsSet.has(task.tags[j])) {
isIncluded = true;
break;
}
}

Check warning on line 57 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMP&open=AZ1Q4YKa1_32ZbbI5oMP&pullRequest=250
}
}

if (isIncluded) {
includedTasks.add(task.name);
}
}

Check warning on line 64 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMN&open=AZ1Q4YKa1_32ZbbI5oMN&pullRequest=250

// 2. Remove explicitly excluded tasks
const excludedTasks = new Set<string>();
for (let i = 0; i < steps.length; i++) {
const task = steps[i];
let isExcluded = false;

if (exclNamesSet && exclNamesSet.has(task.name)) {

Check warning on line 72 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMR&open=AZ1Q4YKa1_32ZbbI5oMR&pullRequest=250
isExcluded = true;
}

if (!isExcluded && exclTagsSet && task.tags) {
for (let j = 0; j < task.tags.length; j++) {
if (exclTagsSet.has(task.tags[j])) {
isExcluded = true;
break;
}
}

Check warning on line 82 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMS&open=AZ1Q4YKa1_32ZbbI5oMS&pullRequest=250
}

if (isExcluded) {
excludedTasks.add(task.name);
includedTasks.delete(task.name);
}
}

Check warning on line 89 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMQ&open=AZ1Q4YKa1_32ZbbI5oMQ&pullRequest=250

// 3. Resolve dependencies if needed
if (includeDependencies) {
const queue = Array.from(includedTasks);
let queueIdx = 0;

while (queueIdx < queue.length) {
const taskName = queue[queueIdx++];
const task = taskMap.get(taskName);

if (task && task.dependencies) {

Check warning on line 100 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMT&open=AZ1Q4YKa1_32ZbbI5oMT&pullRequest=250
for (let i = 0; i < task.dependencies.length; i++) {
const depName = task.dependencies[i];

// If the dependency isn't already included and isn't explicitly excluded
if (!includedTasks.has(depName) && !excludedTasks.has(depName)) {
includedTasks.add(depName);
queue.push(depName);
}
}

Check warning on line 109 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMU&open=AZ1Q4YKa1_32ZbbI5oMU&pullRequest=250
}
}
}

// 4. Return the filtered tasks in the original order
const filteredSteps: TaskStep<TContext>[] = [];
for (let i = 0; i < steps.length; i++) {
if (includedTasks.has(steps[i].name)) {
filteredSteps.push(steps[i]);
}
}

Check warning on line 120 in src/utils/TaskFilter.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected a `for-of` loop instead of a `for` loop with this simple iteration.

See more on https://sonarcloud.io/project/issues?id=thalesraymond_task-runner&issues=AZ1Q4YKa1_32ZbbI5oMV&open=AZ1Q4YKa1_32ZbbI5oMV&pullRequest=250

return filteredSteps;
Comment on lines +15 to +122
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of filterTasks iterates over the steps array multiple times. Consolidating these passes and using a Map for task lookups ensures O(1) access time, which is more efficient for frequent lookups. However, before merging loops for code simplification, consider the performance impact. Retaining separate logic can be justified if it avoids expensive operations and provides significant, benchmarked performance gains.

  const {
    includeTags,
    excludeTags,
    includeNames,
    excludeNames,
    includeDependencies = true,
  } = config;

  const inclTagsSet = includeTags ? new Set(includeTags) : null;
  const exclTagsSet = excludeTags ? new Set(excludeTags) : null;
  const inclNamesSet = includeNames ? new Set(includeNames) : null;
  const exclNamesSet = excludeNames ? new Set(excludeNames) : null;

  const taskMap = new Map<string, TaskStep<TContext>>();
  const includedTasks = new Set<string>();
  const excludedTasks = new Set<string>();

  for (const task of steps) {
    taskMap.set(task.name, task);

    const matchesInclusion = (!inclTagsSet && !inclNamesSet) ||
      (inclNamesSet?.has(task.name)) ||
      (inclTagsSet && task.tags?.some(tag => inclTagsSet.has(tag)));

    const matchesExclusion = (exclNamesSet?.has(task.name)) ||
      (exclTagsSet && task.tags?.some(tag => exclTagsSet.has(tag)));

    if (matchesExclusion) {
      excludedTasks.add(task.name);
    } else if (matchesInclusion) {
      includedTasks.add(task.name);
    }
  }

  if (includeDependencies) {
    const queue = Array.from(includedTasks);
    let queueIdx = 0;
    while (queueIdx < queue.length) {
      const taskName = queue[queueIdx++];
      const task = taskMap.get(taskName);
      if (task?.dependencies) {
        for (const depName of task.dependencies) {
          if (!includedTasks.has(depName) && !excludedTasks.has(depName)) {
            includedTasks.add(depName);
            queue.push(depName);
          }
        }
      }
    }
  }

  return steps.filter((task) => includedTasks.has(task.name));
References
  1. When frequent lookups of items by a specific key are required, use a Map (or dictionary/hash table) to store the items, indexed by that key, to ensure O(1) access time.
  2. Before merging loops or removing duplicate checks for code simplification, consider the performance impact. Retaining separate logic can be justified if it avoids expensive operations and provides significant, benchmarked performance gains.

}
43 changes: 43 additions & 0 deletions tests/TaskRunnerFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from "vitest";
import { TaskRunner } from "../src/TaskRunner.js";
import { TaskStep } from "../src/TaskStep.js";
import { TaskGraphValidationError } from "../src/TaskGraphValidationError.js";

describe("TaskRunner Filtering Integration", () => {
it("should execute only filtered tasks and their dependencies", async () => {
const runner = new TaskRunner({});

const taskA: TaskStep<unknown> = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) };
const taskB: TaskStep<unknown> = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) };
const taskC: TaskStep<unknown> = { name: "taskC", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) };

const steps = [taskA, taskB, taskC];

const results = await runner.execute(steps, {
filter: { includeNames: ["taskB"] }
});

expect(results.size).toBe(2);
expect(results.has("taskA")).toBe(true);
expect(results.has("taskB")).toBe(true);
expect(results.has("taskC")).toBe(false);

expect(taskA.run).toHaveBeenCalled();
expect(taskB.run).toHaveBeenCalled();
expect(taskC.run).not.toHaveBeenCalled();
});

it("should fail validation if a required dependency is explicitly excluded", async () => {
const runner = new TaskRunner({});

const taskA: TaskStep<unknown> = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) };
const taskB: TaskStep<unknown> = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) };

const steps = [taskA, taskB];

// taskB needs taskA, but taskA is excluded by tag
await expect(runner.execute(steps, {
filter: { includeNames: ["taskB"], excludeTags: ["core"] }
})).rejects.toThrow(TaskGraphValidationError);
});
});
64 changes: 64 additions & 0 deletions tests/utils/TaskFilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";
import { filterTasks } from "../../src/utils/TaskFilter.js";
import { TaskStep } from "../../src/TaskStep.js";

describe("filterTasks", () => {
const steps: TaskStep<unknown>[] = [
{ name: "build", tags: ["backend", "core"], run: vi.fn() },
{ name: "test", dependencies: ["build"], tags: ["backend", "testing"], run: vi.fn() },
{ name: "lint", tags: ["frontend", "core"], run: vi.fn() },
{ name: "deploy", dependencies: ["test", "lint"], tags: ["ops"], run: vi.fn() },
{ name: "isolated", tags: ["frontend"], run: vi.fn() }
];

it("should return all tasks if no filter provided", () => {
const result = filterTasks(steps, {});
expect(result.length).toBe(5);
});

it("should include tasks by tag", () => {
const result = filterTasks(steps, { includeTags: ["backend"] });
expect(result.map(s => s.name)).toEqual(["build", "test"]);
});

it("should include tasks by name", () => {
const result = filterTasks(steps, { includeNames: ["lint"] });
expect(result.map(s => s.name)).toEqual(["lint"]);
});

it("should exclude tasks by tag", () => {
const result = filterTasks(steps, { excludeTags: ["ops"] });
expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "isolated"]);
});

it("should exclude tasks by name", () => {
const result = filterTasks(steps, { excludeNames: ["isolated"] });
expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]);
});

it("should handle both include and exclude logic", () => {
const result = filterTasks(steps, { includeTags: ["core"], excludeNames: ["lint"] });
expect(result.map(s => s.name)).toEqual(["build"]);
});

it("should automatically include dependencies if includeDependencies is true", () => {
// 'deploy' depends on 'test' and 'lint', 'test' depends on 'build'
const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: true });
expect(result.map(s => s.name)).toEqual(["build", "test", "lint", "deploy"]);
});

it("should not include dependencies if includeDependencies is false", () => {
const result = filterTasks(steps, { includeNames: ["deploy"], includeDependencies: false });
expect(result.map(s => s.name)).toEqual(["deploy"]);
});

it("should exclude dependencies that match exclude rules", () => {
// Even if 'test' brings in 'build', if 'build' is explicitly excluded, it shouldn't be included.
const result = filterTasks(steps, { includeNames: ["deploy"], excludeTags: ["backend"], includeDependencies: true });
// 'deploy' includes 'test' & 'lint'. 'test' includes 'build'.
// 'test' has tag 'backend' -> excluded.
// 'build' has tag 'backend' -> excluded.
// So only 'lint' and 'deploy' remain.
expect(result.map(s => s.name)).toEqual(["lint", "deploy"]);
});
});
Loading