diff --git a/openspec/changes/add-task-filtering/design.md b/openspec/changes/archive/2026-04-03-add-task-filtering/design.md similarity index 100% rename from openspec/changes/add-task-filtering/design.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/design.md diff --git a/openspec/changes/add-task-filtering/proposal.md b/openspec/changes/archive/2026-04-03-add-task-filtering/proposal.md similarity index 100% rename from openspec/changes/add-task-filtering/proposal.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/proposal.md diff --git a/openspec/changes/add-task-filtering/specs/task-runner/spec.md b/openspec/changes/archive/2026-04-03-add-task-filtering/specs/task-runner/spec.md similarity index 100% rename from openspec/changes/add-task-filtering/specs/task-runner/spec.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/specs/task-runner/spec.md diff --git a/openspec/changes/add-task-filtering/tasks.md b/openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md similarity index 100% rename from openspec/changes/add-task-filtering/tasks.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md diff --git a/openspec/specs/task-runner/spec.md b/openspec/specs/task-runner/spec.md index 83cc950..4a1cd89 100644 --- a/openspec/specs/task-runner/spec.md +++ b/openspec/specs/task-runner/spec.md @@ -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. \ No newline at end of file diff --git a/src/TaskRunner.ts b/src/TaskRunner.ts index 63ba44e..c8beda8 100644 --- a/src/TaskRunner.ts +++ b/src/TaskRunner.ts @@ -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; @@ -176,9 +177,11 @@ export class TaskRunner { // 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 ?? [], })), @@ -210,13 +213,13 @@ export class TaskRunner { 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); } /** diff --git a/src/TaskRunnerExecutionConfig.ts b/src/TaskRunnerExecutionConfig.ts index b417ece..041955c 100644 --- a/src/TaskRunnerExecutionConfig.ts +++ b/src/TaskRunnerExecutionConfig.ts @@ -1,3 +1,5 @@ +import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js"; + /** * Configuration options for TaskRunner execution. */ @@ -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; } diff --git a/src/TaskStep.ts b/src/TaskStep.ts index 10fed0c..3844589 100644 --- a/src/TaskStep.ts +++ b/src/TaskStep.ts @@ -11,6 +11,8 @@ export interface TaskStep { 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. */ diff --git a/src/contracts/TaskFilterConfig.ts b/src/contracts/TaskFilterConfig.ts new file mode 100644 index 0000000..cb398d4 --- /dev/null +++ b/src/contracts/TaskFilterConfig.ts @@ -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; +} diff --git a/src/utils/TaskFilter.ts b/src/utils/TaskFilter.ts new file mode 100644 index 0000000..f6ac506 --- /dev/null +++ b/src/utils/TaskFilter.ts @@ -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( + steps: TaskStep[], + config: TaskFilterConfig +): TaskStep[] { + 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>(); + for (let i = 0; i < steps.length; i++) { + taskMap.set(steps[i].name, steps[i]); + } + + // 1. Determine initially included tasks + const includedTasks = new Set(); + + 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)) { + 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; + } + } + } + } + + if (isIncluded) { + includedTasks.add(task.name); + } + } + + // 2. Remove explicitly excluded tasks + const excludedTasks = new Set(); + for (let i = 0; i < steps.length; i++) { + const task = steps[i]; + let isExcluded = false; + + if (exclNamesSet && exclNamesSet.has(task.name)) { + 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; + } + } + } + + if (isExcluded) { + excludedTasks.add(task.name); + includedTasks.delete(task.name); + } + } + + // 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) { + 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); + } + } + } + } + } + + // 4. Return the filtered tasks in the original order + const filteredSteps: TaskStep[] = []; + for (let i = 0; i < steps.length; i++) { + if (includedTasks.has(steps[i].name)) { + filteredSteps.push(steps[i]); + } + } + + return filteredSteps; +} diff --git a/tests/TaskRunnerFiltering.test.ts b/tests/TaskRunnerFiltering.test.ts new file mode 100644 index 0000000..57d168e --- /dev/null +++ b/tests/TaskRunnerFiltering.test.ts @@ -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 = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskB: TaskStep = { name: "taskB", dependencies: ["taskA"], tags: ["feature"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskC: TaskStep = { 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 = { name: "taskA", tags: ["core"], run: vi.fn().mockResolvedValue({ status: "success" }) }; + const taskB: TaskStep = { 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); + }); +}); diff --git a/tests/utils/TaskFilter.test.ts b/tests/utils/TaskFilter.test.ts new file mode 100644 index 0000000..67252e9 --- /dev/null +++ b/tests/utils/TaskFilter.test.ts @@ -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[] = [ + { 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"]); + }); +});