From ba6e658f483c9b0d4d22e793546477482abc5578 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:49:10 +0000 Subject: [PATCH] feat: Add task filtering and tagging capabilities - Added `tags?: string[]` property to `TaskStep`. - Introduced `TaskFilterConfig` interface to configure filters and dependencies. - Created `src/utils/TaskFilter.ts` containing the `filterTasks` logic. - Updated `TaskRunnerExecutionConfig` with the new optional `filter` property. - Integrated filtering in `TaskRunner.execute` to process steps prior to graph execution. - Added comprehensive unit tests and E2E integration tests to verify functionality. - Marked all spec tasks as complete and archived OpenSpec change `add-task-filtering`. 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 | 16 +- src/TaskRunner.ts | 12 +- src/TaskRunnerExecutionConfig.ts | 9 + src/TaskStep.ts | 2 + src/contracts/TaskFilterConfig.ts | 26 +++ src/utils/TaskFilter.ts | 71 ++++++++ tests/TaskRunnerFiltering.test.ts | 92 ++++++++++ tests/utils/TaskFilter.test.ts | 166 ++++++++++++++++++ 11 files changed, 383 insertions(+), 11 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 (63%) create mode 100644 src/contracts/TaskFilterConfig.ts create mode 100644 src/utils/TaskFilter.ts create mode 100644 tests/TaskRunnerFiltering.test.ts create mode 100644 tests/utils/TaskFilter.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 63% rename from openspec/changes/add-task-filtering/tasks.md rename to openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md index b6bf2cb..8392363 100644 --- a/openspec/changes/add-task-filtering/tasks.md +++ b/openspec/changes/archive/2026-04-03-add-task-filtering/tasks.md @@ -1,13 +1,13 @@ ## 1. Implementation -- [ ] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property. -- [ ] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`. -- [ ] 1.3 Create a utility module `src/utils/TaskFilter.ts`. +- [x] 1.1 Update `TaskStep` interface in `src/TaskStep.ts` to include an optional `tags?: string[]` property. +- [x] 1.2 Define a new type or interface `TaskFilterConfig` in `src/contracts/TaskFilterConfig.ts` with optional properties for `includeTags`, `excludeTags`, `includeNames`, `excludeNames`, and `includeDependencies`. +- [x] 1.3 Create a utility module `src/utils/TaskFilter.ts`. - Implement a pure function `filterTasks(steps: TaskStep[], config: TaskFilterConfig): TaskStep[]`. - Ensure filtering handles both names and tags. -- [ ] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`). +- [x] 1.4 Handle Dependencies during filtering. Implement a configurable flag in `TaskFilterConfig` (e.g., `includeDependencies?: boolean`). - If `true`, recursively include all tasks that the explicitly selected tasks depend on. -- [ ] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`. -- [ ] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`. -- [ ] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly. -- [ ] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution. +- [x] 1.5 Update `TaskRunnerExecutionConfig` in `src/TaskRunnerExecutionConfig.ts` to optionally accept a `filter` of type `TaskFilterConfig`. +- [x] 1.6 Update `TaskRunner.ts` in the `execute` method to apply `filterTasks` to the input steps if `config.filter` is provided, *before* passing the subset to `WorkflowExecutor`. +- [x] 1.7 Write unit tests for `TaskFilter.ts` ensuring inclusion, exclusion, and dependency resolution work correctly. +- [x] 1.8 Write integration tests for `TaskRunner` filtering in `tests/TaskRunnerFiltering.test.ts` to verify end-to-end filtering execution. diff --git a/src/TaskRunner.ts b/src/TaskRunner.ts index 63ba44e..ca93f50 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,14 @@ export class TaskRunner { // Initialize plugins await this.pluginManager.initialize(); + // Apply optional filtering before execution + 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 +216,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..b819082 100644 --- a/src/TaskRunnerExecutionConfig.ts +++ b/src/TaskRunnerExecutionConfig.ts @@ -1,7 +1,16 @@ +/** + * Configuration options for TaskRunner execution. + */ +import { TaskFilterConfig } from "./contracts/TaskFilterConfig.js"; + /** * Configuration options for TaskRunner execution. */ export interface TaskRunnerExecutionConfig { + /** + * Optional filtering configuration to selectively execute tasks. + */ + filter?: TaskFilterConfig; /** * An AbortSignal to cancel the workflow externally. */ diff --git a/src/TaskStep.ts b/src/TaskStep.ts index 10fed0c..6d5f440 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 to categorize 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..cdff5a8 --- /dev/null +++ b/src/contracts/TaskFilterConfig.ts @@ -0,0 +1,26 @@ +/** + * Configuration options for filtering tasks during execution. + */ +export interface TaskFilterConfig { + /** + * Run only tasks with these tags. + */ + includeTags?: string[]; + /** + * Exclude tasks with these tags. + */ + excludeTags?: string[]; + /** + * Run only tasks with these names. + */ + includeNames?: string[]; + /** + * Exclude tasks with these names. + */ + excludeNames?: string[]; + /** + * If true, automatically include dependencies of selected tasks. + * Default is false. + */ + includeDependencies?: boolean; +} diff --git a/src/utils/TaskFilter.ts b/src/utils/TaskFilter.ts new file mode 100644 index 0000000..d8d7dee --- /dev/null +++ b/src/utils/TaskFilter.ts @@ -0,0 +1,71 @@ +import { TaskStep } from "../TaskStep.js"; +import { TaskFilterConfig } from "../contracts/TaskFilterConfig.js"; + +/** + * Filters a list of tasks based on the provided configuration. + * + * @param steps The original array of tasks. + * @param config The filtering configuration. + * @returns A new array containing the filtered tasks. + */ +export function filterTasks( + steps: TaskStep[], + config: TaskFilterConfig +): TaskStep[] { + const stepMap = new Map(steps.map((step) => [step.name, step])); + + const filteredSteps = steps.filter((step) => { + // 1. Check exclusions first (highest priority) + if ( + config.excludeNames?.includes(step.name) || + (step.tags && config.excludeTags?.some((tag) => step.tags!.includes(tag))) + ) { + return false; + } + + // 2. Check inclusions (if both are provided, satisfying either is enough or requires both? Usually OR semantics for inclusions) + // Actually, usually if include is present, it MUST match one of the inclusions. + // Let's implement OR logic: if included by name OR included by tag. + const hasIncludeNames = + config.includeNames && config.includeNames.length > 0; + const hasIncludeTags = config.includeTags && config.includeTags.length > 0; + + if (!hasIncludeNames && !hasIncludeTags) { + return true; // No inclusion filters, so keep it if it passed exclusion + } + + const includedByName = hasIncludeNames && config.includeNames!.includes(step.name); + const includedByTag = + hasIncludeTags && + step.tags && + config.includeTags!.some((tag) => step.tags!.includes(tag)); + + return includedByName || includedByTag; + }); + + if (!config.includeDependencies) { + return filteredSteps; + } + + // Include dependencies recursively + const resultSet = new Set(filteredSteps.map((s) => s.name)); + const queue = [...filteredSteps]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.dependencies) { + for (const depName of current.dependencies) { + if (!resultSet.has(depName)) { + resultSet.add(depName); + const depStep = stepMap.get(depName); + if (depStep) { + queue.push(depStep); + } + } + } + } + } + + // Preserve original order and map names back to steps + return steps.filter((step) => resultSet.has(step.name)); +} diff --git a/tests/TaskRunnerFiltering.test.ts b/tests/TaskRunnerFiltering.test.ts new file mode 100644 index 0000000..bbc89d7 --- /dev/null +++ b/tests/TaskRunnerFiltering.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { TaskRunner } from "../src/TaskRunner.js"; +import { TaskStep } from "../src/TaskStep.js"; + +interface TestContext { + executedTasks: string[]; +} + +const createTestTask = ( + name: string, + dependencies?: string[], + tags?: string[] +): TaskStep => ({ + name, + dependencies, + tags, + run: async (ctx) => { + ctx.executedTasks.push(name); + return { status: "success" }; + }, +}); + +describe("TaskRunner Filtering End-to-End", () => { + const steps: TaskStep[] = [ + createTestTask("task-1", [], ["setup"]), + createTestTask("task-2", ["task-1"], ["build"]), + createTestTask("task-3", ["task-2"], ["test", "unit"]), + createTestTask("task-4", ["task-2"], ["test", "e2e"]), + ]; + + it("should run all tasks when no filter is provided", async () => { + const context: TestContext = { executedTasks: [] }; + const runner = new TaskRunner(context); + + await runner.execute(steps); + + expect(context.executedTasks.length).toBe(4); + expect(context.executedTasks).toContain("task-1"); + expect(context.executedTasks).toContain("task-2"); + expect(context.executedTasks).toContain("task-3"); + expect(context.executedTasks).toContain("task-4"); + }); + + it("should execute only the filtered tasks by tag", async () => { + const context: TestContext = { executedTasks: [] }; + const runner = new TaskRunner(context); + + // Filter only "test" tasks, omitting dependencies + // task-3 and task-4 depend on task-2. In reality, the graph would fail validation if we omit dependencies, + // but our execution config should handle it or fail validation if dependencies are missing and includeDependencies is false. + // Wait, TaskGraphValidator checks if dependencies exist in the passed graph. + // Let's test with includeDependencies: true to ensure valid execution graph. + await runner.execute(steps, { + filter: { + includeTags: ["test"], + includeDependencies: true, + }, + }); + + expect(context.executedTasks.length).toBe(4); // includes 1, 2, 3, 4 + }); + + it("should execute only the explicitly included task when valid isolated graph", async () => { + const context: TestContext = { executedTasks: [] }; + const runner = new TaskRunner(context); + + await runner.execute(steps, { + filter: { + includeNames: ["task-1"], + }, + }); + + expect(context.executedTasks.length).toBe(1); + expect(context.executedTasks).toEqual(["task-1"]); + }); + + it("should fail validation if filtering creates broken dependency graph", async () => { + const context: TestContext = { executedTasks: [] }; + const runner = new TaskRunner(context); + + // This will include task-2 which depends on task-1, but task-1 is filtered out. + // TaskGraphValidator should throw. + await expect( + runner.execute(steps, { + filter: { + includeNames: ["task-2"], + includeDependencies: false, + }, + }) + ).rejects.toThrow(); + }); +}); diff --git a/tests/utils/TaskFilter.test.ts b/tests/utils/TaskFilter.test.ts new file mode 100644 index 0000000..756a9ea --- /dev/null +++ b/tests/utils/TaskFilter.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { filterTasks } from "../../src/utils/TaskFilter.js"; +import { TaskStep } from "../../src/TaskStep.js"; + +interface TestContext { + dummy?: boolean; +} + +const createMockTask = ( + name: string, + dependencies?: string[], + tags?: string[] +): TaskStep => ({ + name, + dependencies, + tags, + run: async () => ({ status: "success" as const }), +}); + +describe("TaskFilter", () => { + const steps: TaskStep[] = [ + createMockTask("task-a", [], ["core", "setup"]), + createMockTask("task-b", ["task-a"], ["build", "frontend"]), + createMockTask("task-c", ["task-b"], ["test", "frontend"]), + createMockTask("task-d", ["task-a"], ["build", "backend"]), + createMockTask("task-e", ["task-d"], ["test", "backend"]), + createMockTask("task-f", ["task-c", "task-e"], ["deploy"]), + ]; + + it("should return all tasks when no filter is provided", () => { + const result = filterTasks(steps, {}); + expect(result.length).toBe(6); + expect(result).toEqual(steps); + }); + + describe("Filtering by name", () => { + it("should include only specified names", () => { + const result = filterTasks(steps, { includeNames: ["task-a", "task-c"] }); + expect(result.length).toBe(2); + expect(result.map((r) => r.name)).toEqual(["task-a", "task-c"]); + }); + + it("should exclude specified names", () => { + const result = filterTasks(steps, { excludeNames: ["task-f", "task-e"] }); + expect(result.length).toBe(4); + expect(result.map((r) => r.name)).toEqual([ + "task-a", + "task-b", + "task-c", + "task-d", + ]); + }); + }); + + describe("Filtering by tag", () => { + it("should include only specified tags", () => { + const result = filterTasks(steps, { includeTags: ["frontend"] }); + expect(result.length).toBe(2); + expect(result.map((r) => r.name)).toEqual(["task-b", "task-c"]); + }); + + it("should include matching multiple tags (OR logic)", () => { + const result = filterTasks(steps, { includeTags: ["test", "setup"] }); + expect(result.length).toBe(3); + expect(result.map((r) => r.name)).toEqual(["task-a", "task-c", "task-e"]); + }); + + it("should exclude specified tags", () => { + const result = filterTasks(steps, { excludeTags: ["backend", "deploy"] }); + expect(result.length).toBe(3); + expect(result.map((r) => r.name)).toEqual(["task-a", "task-b", "task-c"]); + }); + }); + + describe("Combined filtering", () => { + it("should fallback to true if no valid inclusion values are provided", () => { + const result = filterTasks(steps, { + includeTags: [], + includeNames: [], + }); + expect(result.length).toBe(6); + }); + + it("should include by tag OR name", () => { + const result = filterTasks(steps, { + includeTags: ["setup"], + includeNames: ["task-f"], + }); + expect(result.length).toBe(2); + expect(result.map((r) => r.name)).toEqual(["task-a", "task-f"]); + }); + + it("should exclude taking precedence over include", () => { + const result = filterTasks(steps, { + includeTags: ["frontend", "backend"], + excludeTags: ["test"], + excludeNames: ["task-b"], + }); + // Should include build-frontend and build-backend (task-b, task-d) + // Exclude test tags (task-c, task-e) + // Exclude name task-b + // Result: only task-d + expect(result.length).toBe(1); + expect(result.map((r) => r.name)).toEqual(["task-d"]); + }); + }); + + describe("Dependency Resolution", () => { + it("should handle current without dependencies array in queue", () => { + const stepNoDeps = createMockTask("task-no-deps"); + // Add a task without dependencies to test queue processing logic branch where current.dependencies is falsy. + const result = filterTasks([stepNoDeps], { + includeNames: ["task-no-deps"], + includeDependencies: true, + }); + expect(result.length).toBe(1); + }); + + it("should safely ignore missing dependencies in the given steps array", () => { + const incompleteSteps: TaskStep[] = [ + createMockTask("task-a", []), + createMockTask("task-b", ["task-missing"]), + ]; + const result = filterTasks(incompleteSteps, { + includeNames: ["task-b"], + includeDependencies: true, + }); + // Should gracefully skip missing dependencies and just return the task + expect(result.length).toBe(1); + expect(result[0].name).toBe("task-b"); + }); + + it("should include dependencies recursively when flag is true", () => { + const result = filterTasks(steps, { + includeNames: ["task-f"], + includeDependencies: true, + }); + // task-f depends on task-c and task-e + // task-c depends on task-b + // task-b depends on task-a + // task-e depends on task-d + // task-d depends on task-a + // Everything should be included + expect(result.length).toBe(6); + }); + + it("should include missing dependencies for tags", () => { + const result = filterTasks(steps, { + includeTags: ["test"], + includeDependencies: true, + }); + // Test tasks: task-c, task-e + // task-c needs task-b, task-a + // task-e needs task-d, task-a + // Should include a, b, c, d, e (no f) + expect(result.length).toBe(5); + expect(result.map((r) => r.name)).toEqual([ + "task-a", + "task-b", + "task-c", + "task-d", + "task-e", + ]); + }); + }); +});