From bb62a94951b31499cfa0a903a0f8d787767f168b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:53:03 +0000 Subject: [PATCH] feat: implement task filtering by tags and names This commit fully implements the `add-task-filtering` specification. - Updated `TaskStep` to include an optional `tags` string array. - Added `TaskFilterConfig` for configuring `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`. - Created `filterTasks` utility to correctly resolve inclusive and exclusive combinations while optionally resolving dependency closures. - Configured `TaskRunner` to conditionally apply `filterTasks` to input steps before executing or validating the workflow logic. - Added comprehensive unit and integration tests. - Archived the spec manually by moving it to `openspec/changes/archive/`. Co-authored-by: thalesraymond <32554150+thalesraymond@users.noreply.github.com> --- .../2026-04-03-add-task-filtering}/design.md | 0 .../proposal.md | 0 .../specs/task-runner/spec.md | 0 .../2026-04-03-add-task-filtering}/tasks.md | 0 src/TaskRunner.ts | 11 +- src/TaskRunnerExecutionConfig.ts | 6 + src/TaskStep.ts | 2 + src/contracts/TaskFilterConfig.ts | 26 +++ src/utils/TaskFilter.ts | 93 +++++++++++ tests/TaskFilter.test.ts | 154 ++++++++++++++++++ tests/TaskRunnerFiltering.test.ts | 99 +++++++++++ 11 files changed, 388 insertions(+), 3 deletions(-) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/design.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/proposal.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/specs/task-runner/spec.md (100%) rename openspec/changes/{add-task-filtering => archive/2026-04-03-add-task-filtering}/tasks.md (100%) create mode 100644 src/contracts/TaskFilterConfig.ts create mode 100644 src/utils/TaskFilter.ts create mode 100644 tests/TaskFilter.test.ts create mode 100644 tests/TaskRunnerFiltering.test.ts 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/src/TaskRunner.ts b/src/TaskRunner.ts index 63ba44e..83016be 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,13 @@ export class TaskRunner { // Initialize plugins await this.pluginManager.initialize(); + const tasksToExecute = config?.filter + ? filterTasks(steps, config.filter) + : steps; + // Validate the task graph before execution const taskGraph: TaskGraph = { - tasks: steps.map((step) => ({ + tasks: tasksToExecute.map((step) => ({ id: step.name, dependencies: step.dependencies ?? [], })), @@ -210,13 +215,13 @@ export class TaskRunner { if (config?.timeout !== undefined) { return this.executeWithTimeout( executor, - steps, + tasksToExecute, config.timeout, config.signal ); } - return executor.execute(steps, config?.signal); + return executor.execute(tasksToExecute, config?.signal); } /** diff --git a/src/TaskRunnerExecutionConfig.ts b/src/TaskRunnerExecutionConfig.ts index b417ece..c7e6dc5 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; + /** + * Configuration for filtering the tasks to be executed. + */ + filter?: TaskFilterConfig; } diff --git a/src/TaskStep.ts b/src/TaskStep.ts index 10fed0c..0b59212 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 tags for filtering tasks. */ + 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..b049080 --- /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 have at least one of these tags. + */ + includeTags?: string[]; + /** + * Exclude tasks that have any of these tags. + */ + excludeTags?: string[]; + /** + * Only include tasks with these specific names. + */ + includeNames?: string[]; + /** + * Exclude tasks with these specific names. + */ + excludeNames?: string[]; + /** + * If true, dependencies of included tasks will also be included automatically. + * Default is false. + */ + includeDependencies?: boolean; +} diff --git a/src/utils/TaskFilter.ts b/src/utils/TaskFilter.ts new file mode 100644 index 0000000..664a968 --- /dev/null +++ b/src/utils/TaskFilter.ts @@ -0,0 +1,93 @@ +import { TaskStep } from "../TaskStep.js"; +import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; + +/** + * Filters an array of tasks based on the provided configuration. + * + * @param steps The array of tasks to filter. + * @param config The filter configuration. + * @returns A new array of tasks that match the filter criteria. + */ +export function filterTasks( + steps: TaskStep[], + config: TaskFilterConfig +): TaskStep[] { + const stepMap = new Map>(); + for (const step of steps) { + stepMap.set(step.name, step); + } + + const initialMatches = new Set(); + + for (const step of steps) { + let included = true; + + // Check inclusion criteria + if (!config.includeNames?.length && !config.includeTags?.length) { + included = true; + } else if (config.includeNames?.length && config.includeTags?.length) { + // If both are provided, we should match either. + const nameMatch = config.includeNames.includes(step.name); + const tagMatch = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag)); + + included = nameMatch || tagMatch; + } else if (config.includeNames?.length) { + included = config.includeNames.includes(step.name); + } else { + // Only includeTags is provided + included = !!step.tags && step.tags.some(tag => config.includeTags!.includes(tag)); + } + + // Check exclusion criteria + if (config.excludeNames && config.excludeNames.includes(step.name)) { + included = false; + } + + if (config.excludeTags && !!step.tags && step.tags.some(tag => config.excludeTags!.includes(tag))) { + included = false; + } + + if (included) { + initialMatches.add(step.name); + } + } + + // Handle dependencies + if (config.includeDependencies) { + const queue = Array.from(initialMatches); + const visited = new Set(initialMatches); + + while (queue.length > 0) { + const currentName = queue.shift()!; + const currentStep = stepMap.get(currentName); + + if (currentStep && currentStep.dependencies) { + for (const dep of currentStep.dependencies) { + // If a dependency is excluded, we might not include it, but + // standard behavior is usually to override exclusion for explicit dependencies, + // or fail. Let's include it unless it's explicitly excluded. + // Wait, if it's explicitly excluded, it might fail the pipeline. + // For now, if we include dependencies, we'll just add them unless they are in exclusions. + let depExcluded = false; + const depStep = stepMap.get(dep); + + if (config.excludeNames && config.excludeNames.includes(dep)) { + depExcluded = true; + } + if (depStep && config.excludeTags && depStep.tags && depStep.tags.some(tag => config.excludeTags!.includes(tag))) { + depExcluded = true; + } + + if (!visited.has(dep) && !depExcluded) { + visited.add(dep); + queue.push(dep); + initialMatches.add(dep); + } + } + } + } + } + + // Filter the final list, preserving original order + return steps.filter(step => initialMatches.has(step.name)); +} diff --git a/tests/TaskFilter.test.ts b/tests/TaskFilter.test.ts new file mode 100644 index 0000000..5294a3b --- /dev/null +++ b/tests/TaskFilter.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from "vitest"; +import { filterTasks } from "../src/utils/TaskFilter.js"; +import { TaskStep } from "../src/TaskStep.js"; +import { TaskResult } from "../src/TaskResult.js"; + +describe("TaskFilter", () => { + const mockRun = vi.fn().mockResolvedValue({ status: "success" } as TaskResult); + + const steps: TaskStep[] = [ + { name: "taskA", tags: ["frontend", "build"], run: mockRun }, + { name: "taskB", tags: ["backend", "build"], dependencies: ["taskA"], run: mockRun }, + { name: "taskC", tags: ["frontend", "test"], run: mockRun }, + { name: "taskD", dependencies: ["taskB"], run: mockRun }, + { name: "taskE", tags: ["deploy"], dependencies: ["taskC", "taskD"], run: mockRun }, + ]; + + it("should include tasks by exact names", () => { + const result = filterTasks(steps, { includeNames: ["taskA", "taskC"] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]); + }); + + it("should include tasks by tags", () => { + const result = filterTasks(steps, { includeTags: ["frontend"] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskC"]); + }); + + it("should include tasks matching either names or tags", () => { + const result = filterTasks(steps, { + includeNames: ["taskD"], + includeTags: ["test"], + }); + expect(result.map((s) => s.name)).toEqual(["taskC", "taskD"]); + }); + + it("should handle includeTags when includeNames is undefined", () => { + const result = filterTasks(steps, { + includeNames: undefined, + includeTags: ["test"], + }); + expect(result.map((s) => s.name)).toEqual(["taskC"]); + }); + + it("should handle includeNames when includeTags is undefined", () => { + const result = filterTasks(steps, { + includeNames: ["taskD"], + includeTags: undefined, + }); + expect(result.map((s) => s.name)).toEqual(["taskD"]); + }); + + it("should exclude tasks by exact names", () => { + const result = filterTasks(steps, { excludeNames: ["taskB", "taskC"] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskD", "taskE"]); + }); + + it("should exclude tasks by tags", () => { + const result = filterTasks(steps, { excludeTags: ["build"] }); + expect(result.map((s) => s.name)).toEqual(["taskC", "taskD", "taskE"]); + }); + + it("should exclude taking precedence over include", () => { + const result = filterTasks(steps, { + includeTags: ["frontend"], + excludeNames: ["taskC"], + }); + expect(result.map((s) => s.name)).toEqual(["taskA"]); + }); + + it("should include dependencies when includeDependencies is true", () => { + const result = filterTasks(steps, { + includeNames: ["taskD"], + includeDependencies: true, + }); + // taskD depends on taskB, which depends on taskA + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskD"]); + }); + + it("should not include excluded dependencies even if includeDependencies is true", () => { + const result = filterTasks(steps, { + includeNames: ["taskE"], + includeDependencies: true, + excludeTags: ["backend"], + }); + expect(result.map((s) => s.name).sort()).toEqual(["taskC", "taskD", "taskE"].sort()); + }); + + it("should not include excluded dependencies by name even if includeDependencies is true", () => { + const result = filterTasks(steps, { + includeNames: ["taskD"], + includeDependencies: true, + excludeNames: ["taskB"], + }); + // taskD depends on taskB, which is excluded by name. + expect(result.map((s) => s.name)).toEqual(["taskD"]); + }); + + it("should handle missing dependencies gracefully when includeDependencies is true", () => { + const missingDepSteps: TaskStep[] = [ + { name: "task1", dependencies: ["missingTask"], run: mockRun } + ]; + const result = filterTasks(missingDepSteps, { + includeNames: ["task1"], + includeDependencies: true, + }); + expect(result.map((s) => s.name)).toEqual(["task1"]); + }); + + it("should handle default inclusion when neither includeNames nor includeTags are present but there is a truthy config with no properties matching", () => { + const result = filterTasks(steps, { includeNames: [], includeTags: [] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); + }); + + it("should default to false if both include criteria exist but neither is matched", () => { + const stepMissingBoth: TaskStep[] = [ + { name: "taskNonMatching", run: mockRun } + ]; + const result = filterTasks(stepMissingBoth, { includeNames: ["a"], includeTags: ["b"] }); + expect(result.map((s) => s.name)).toEqual([]); + }); + + it("should handle empty exclusions", () => { + const result = filterTasks(steps, { excludeNames: [], excludeTags: [] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); + }); + + it("should handle exclude tags gracefully if tasks have no tags", () => { + const taskWithoutTags: TaskStep[] = [ + { name: "noTagTask", run: mockRun } + ]; + const result = filterTasks(taskWithoutTags, { excludeTags: ["frontend"] }); + expect(result.map((s) => s.name)).toEqual(["noTagTask"]); + }); + + it("should default to included if inclusion conditions exists but are mutually falsy because missing values", () => { + const result = filterTasks(steps, { includeNames: [], includeTags: [] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); + }); + + it("should handle both includeNames and includeTags logic correctly", () => { + const mixedSteps: TaskStep[] = [ + { name: "taskA", tags: ["frontend"], run: mockRun }, + { name: "taskB", tags: ["backend"], run: mockRun }, + { name: "taskC", tags: ["other"], run: mockRun }, + ]; + // taskA matched by name, taskB matched by tag + const result = filterTasks(mixedSteps, { includeNames: ["taskA"], includeTags: ["backend"] }); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB"]); + }); + + it("should return all tasks when no inclusion/exclusion criteria are provided", () => { + const result = filterTasks(steps, {}); + expect(result.map((s) => s.name)).toEqual(["taskA", "taskB", "taskC", "taskD", "taskE"]); + }); +}); diff --git a/tests/TaskRunnerFiltering.test.ts b/tests/TaskRunnerFiltering.test.ts new file mode 100644 index 0000000..f60ba8f --- /dev/null +++ b/tests/TaskRunnerFiltering.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { TaskRunner } from "../src/TaskRunner.js"; +import { TaskStep } from "../src/TaskStep.js"; + +describe("TaskRunner Filtering Integration", () => { + it("should execute only the filtered tasks and respect dependencies", async () => { + const runner = new TaskRunner({}); + const executed: string[] = []; + + const createStep = (name: string, tags: string[] = [], dependencies: string[] = []): TaskStep => ({ + name, + tags, + dependencies, + run: async () => { + executed.push(name); + return { status: "success" }; + }, + }); + + const steps = [ + createStep("task1", ["groupA"]), + createStep("task2", ["groupB"], ["task1"]), + createStep("task3", ["groupA"], ["task2"]), + createStep("task4", ["groupC"]), + ]; + + // Execution with filter: include groupC and groupA + const result = await runner.execute(steps, { + filter: { + includeTags: ["groupC"], + }, + }); + + expect(result.size).toBe(1); + expect(executed).toEqual(["task4"]); + }); + + it("should fail validation if an included task's dependency is missing because of filter and includeDependencies is false", async () => { + const runner = new TaskRunner({}); + const executed: string[] = []; + + const createStep = (name: string, tags: string[] = [], dependencies: string[] = []): TaskStep => ({ + name, + tags, + dependencies, + run: async () => { + executed.push(name); + return { status: "success" }; + }, + }); + + const steps = [ + createStep("task1", ["groupA"]), + createStep("task2", ["groupB"], ["task1"]), + ]; + + // Execute with filter that includes task2 but NOT task1, and includeDependencies is false + // Since task1 is required by task2 but is missing from the filtered list, TaskGraphValidator should throw an error. + await expect( + runner.execute(steps, { + filter: { + includeNames: ["task2"], + includeDependencies: false, + }, + }) + ).rejects.toThrow(); // The validation error for missing dependency + }); + + it("should successfully execute if includeDependencies is true", async () => { + const runner = new TaskRunner({}); + const executed: string[] = []; + + const createStep = (name: string, tags: string[] = [], dependencies: string[] = []): TaskStep => ({ + name, + tags, + dependencies, + run: async () => { + executed.push(name); + return { status: "success" }; + }, + }); + + const steps = [ + createStep("task1", ["groupA"]), + createStep("task2", ["groupB"], ["task1"]), + ]; + + const result = await runner.execute(steps, { + filter: { + includeNames: ["task2"], + includeDependencies: true, + }, + }); + + expect(result.size).toBe(2); + // Because of dependency, task1 must execute before task2 + expect(executed).toEqual(["task1", "task2"]); + }); +});