diff --git a/openspec/changes/feat-task-caching/design.md b/openspec/changes/archive/2024-05-15-feat-task-caching-done/design.md similarity index 100% rename from openspec/changes/feat-task-caching/design.md rename to openspec/changes/archive/2024-05-15-feat-task-caching-done/design.md diff --git a/openspec/changes/feat-task-caching/proposal.md b/openspec/changes/archive/2024-05-15-feat-task-caching-done/proposal.md similarity index 100% rename from openspec/changes/feat-task-caching/proposal.md rename to openspec/changes/archive/2024-05-15-feat-task-caching-done/proposal.md diff --git a/openspec/changes/feat-task-caching/specs/task-runner/spec.md b/openspec/changes/archive/2024-05-15-feat-task-caching-done/specs/task-runner/spec.md similarity index 100% rename from openspec/changes/feat-task-caching/specs/task-runner/spec.md rename to openspec/changes/archive/2024-05-15-feat-task-caching-done/specs/task-runner/spec.md diff --git a/openspec/changes/feat-task-caching/tasks.md b/openspec/changes/archive/2024-05-15-feat-task-caching-done/tasks.md similarity index 71% rename from openspec/changes/feat-task-caching/tasks.md rename to openspec/changes/archive/2024-05-15-feat-task-caching-done/tasks.md index 9afb30a..56947b8 100644 --- a/openspec/changes/feat-task-caching/tasks.md +++ b/openspec/changes/archive/2024-05-15-feat-task-caching-done/tasks.md @@ -1,12 +1,12 @@ ## 1. Implementation -- [ ] 1.1 Define `ICacheProvider` interface in `src/contracts/ICacheProvider.ts` with `get(key)`, `set(key, result, ttl)`, and `delete(key)` methods. -- [ ] 1.2 Implement `MemoryCacheProvider` in `src/utils/MemoryCacheProvider.ts` as the default in-memory cache implementation. -- [ ] 1.3 Update `TaskStep` interface in `src/TaskStep.ts` to include optional `cache` configuration: +- [x] 1.1 Define `ICacheProvider` interface in `src/contracts/ICacheProvider.ts` with `get(key)`, `set(key, result, ttl)`, and `delete(key)` methods. +- [x] 1.2 Implement `MemoryCacheProvider` in `src/utils/MemoryCacheProvider.ts` as the default in-memory cache implementation. +- [x] 1.3 Update `TaskStep` interface in `src/TaskStep.ts` to include optional `cache` configuration: - `key`: `(context: TContext) => string | Promise` - `ttl`: `number` (optional, default to infinite) - `restore`: `(context: TContext, cachedResult: TaskResult) => void | Promise` (optional, to re-apply context side effects) -- [ ] 1.4 Create `CachingExecutionStrategy` in `src/strategies/CachingExecutionStrategy.ts`. +- [x] 1.4 Create `CachingExecutionStrategy` in `src/strategies/CachingExecutionStrategy.ts`. - It should implement `IExecutionStrategy`. - It should accept an inner `IExecutionStrategy` and an `ICacheProvider`. - In `execute`: @@ -18,7 +18,7 @@ - Execute inner strategy. - If successful, store result in cache provider using `ttl`. - Return result. -- [ ] 1.5 Update `TaskRunner.ts` to support configuring the cache provider and wrapping the execution strategy with `CachingExecutionStrategy` if caching is enabled. -- [ ] 1.6 Add unit tests for `MemoryCacheProvider`. -- [ ] 1.7 Add unit tests for `CachingExecutionStrategy`, verifying cache hits, misses, and restoration of context. -- [ ] 1.8 Add integration tests in `tests/TaskRunnerCaching.test.ts` to verify end-to-end caching behavior with context updates. +- [x] 1.5 Update `TaskRunner.ts` to support configuring the cache provider and wrapping the execution strategy with `CachingExecutionStrategy` if caching is enabled. +- [x] 1.6 Add unit tests for `MemoryCacheProvider`. +- [x] 1.7 Add unit tests for `CachingExecutionStrategy`, verifying cache hits, misses, and restoration of context. +- [x] 1.8 Add integration tests in `tests/TaskRunnerCaching.test.ts` to verify end-to-end caching behavior with context updates. diff --git a/src/TaskRunner.ts b/src/TaskRunner.ts index 63ba44e..a082a05 100644 --- a/src/TaskRunner.ts +++ b/src/TaskRunner.ts @@ -17,6 +17,8 @@ import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrateg import { Plugin } from "./contracts/Plugin.js"; import { PluginManager } from "./PluginManager.js"; import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js"; +import { ICacheProvider } from "./contracts/ICacheProvider.js"; +import { CachingExecutionStrategy } from "./strategies/CachingExecutionStrategy.js"; const MERMAID_ID_REGEX = /[^a-zA-Z0-9_-]/g; @@ -32,6 +34,7 @@ export class TaskRunner { new RetryingExecutionStrategy(new StandardExecutionStrategy()); private readonly pluginManager: PluginManager; + private cacheProvider?: ICacheProvider; /** * @param context The shared context object to be passed to each task. @@ -40,6 +43,16 @@ export class TaskRunner { this.pluginManager = new PluginManager({ events: this.eventBus }); } + /** + * Sets the cache provider to be used for task caching. + * @param provider The cache provider implementation. + * @returns The TaskRunner instance for chaining. + */ + public setCacheProvider(provider: ICacheProvider): this { + this.cacheProvider = provider; + return this; + } + /** * Subscribe to an event. * @param event The event name. @@ -197,6 +210,8 @@ export class TaskRunner { let strategy = this.executionStrategy; if (config?.dryRun) { strategy = new DryRunExecutionStrategy(); + } else if (this.cacheProvider) { + strategy = new CachingExecutionStrategy(strategy, this.cacheProvider); } const executor = new WorkflowExecutor( diff --git a/src/TaskStep.ts b/src/TaskStep.ts index 10fed0c..be895fe 100644 --- a/src/TaskStep.ts +++ b/src/TaskStep.ts @@ -2,6 +2,27 @@ import { TaskResult } from "./TaskResult.js"; import { TaskRetryConfig } from "./contracts/TaskRetryConfig.js"; import { TaskLoopConfig } from "./contracts/TaskLoopConfig.js"; +/** + * Configuration for task output caching. + * @template TContext The shape of the shared context object. + */ +export interface TaskCacheConfig { + /** + * Function to determine the cache key based on the context. + */ + key: (context: TContext) => string | Promise; + + /** + * Optional time-to-live for the cached result in milliseconds. + */ + ttl?: number; + + /** + * Optional function to re-apply context side effects from a cached result. + */ + restore?: (context: TContext, cachedResult: TaskResult) => void | Promise; +} + /** * Represents a single, executable step within a workflow. * @template TContext The shape of the shared context object. @@ -15,6 +36,8 @@ export interface TaskStep { retry?: TaskRetryConfig; /** Optional loop configuration for the task. */ loop?: TaskLoopConfig; + /** Optional cache configuration for the task. */ + cache?: TaskCacheConfig; /** * Optional function to determine if the task should run. * If it returns false (synchronously or asynchronously), the task is skipped. diff --git a/src/contracts/ICacheProvider.ts b/src/contracts/ICacheProvider.ts new file mode 100644 index 0000000..143d89d --- /dev/null +++ b/src/contracts/ICacheProvider.ts @@ -0,0 +1,29 @@ +import { TaskResult } from "../TaskResult.js"; + +/** + * Interface for a cache provider used to store and retrieve task execution results. + */ +export interface ICacheProvider { + /** + * Retrieves a cached result by its key. + * @param key The unique cache key. + * @returns A promise resolving to the cached TaskResult, or undefined if not found or expired. + */ + get(key: string): Promise; + + /** + * Stores a task result in the cache. + * @param key The unique cache key. + * @param result The task result to store. + * @param ttl Optional time-to-live in milliseconds. If omitted, the cache may be indefinite. + * @returns A promise that resolves when the store operation completes. + */ + set(key: string, result: TaskResult, ttl?: number): Promise; + + /** + * Deletes a cached result by its key. + * @param key The unique cache key. + * @returns A promise that resolves when the delete operation completes. + */ + delete(key: string): Promise; +} diff --git a/src/strategies/CachingExecutionStrategy.ts b/src/strategies/CachingExecutionStrategy.ts new file mode 100644 index 0000000..9f3b3e4 --- /dev/null +++ b/src/strategies/CachingExecutionStrategy.ts @@ -0,0 +1,46 @@ +import { IExecutionStrategy } from "./IExecutionStrategy.js"; +import { TaskStep } from "../TaskStep.js"; +import { TaskResult } from "../TaskResult.js"; +import { ICacheProvider } from "../contracts/ICacheProvider.js"; + +/** + * An execution strategy that wraps another strategy to provide caching capabilities. + * @template TContext The shape of the shared context object. + */ +export class CachingExecutionStrategy implements IExecutionStrategy { + constructor( + private readonly innerStrategy: IExecutionStrategy, + private readonly cacheProvider: ICacheProvider + ) {} + + async execute( + step: TaskStep, + context: TContext, + signal?: AbortSignal + ): Promise { + if (!step.cache) { + return this.innerStrategy.execute(step, context, signal); + } + + const cacheKey = await step.cache.key(context); + const cachedResult = await this.cacheProvider.get(cacheKey); + + if (cachedResult !== undefined) { + if (step.cache.restore) { + await step.cache.restore(context, cachedResult); + } + return { + ...cachedResult, + status: "skipped", // or "cached" if we add it to TaskStatus + }; + } + + const result = await this.innerStrategy.execute(step, context, signal); + + if (result.status === "success") { + await this.cacheProvider.set(cacheKey, result, step.cache.ttl); + } + + return result; + } +} diff --git a/src/utils/MemoryCacheProvider.ts b/src/utils/MemoryCacheProvider.ts new file mode 100644 index 0000000..00d58b6 --- /dev/null +++ b/src/utils/MemoryCacheProvider.ts @@ -0,0 +1,32 @@ +import { ICacheProvider } from "../contracts/ICacheProvider.js"; +import { TaskResult } from "../TaskResult.js"; + +/** + * An in-memory implementation of ICacheProvider. + */ +export class MemoryCacheProvider implements ICacheProvider { + private readonly cache = new Map(); + + async get(key: string): Promise { + const entry = this.cache.get(key); + if (!entry) { + return undefined; + } + + if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + + return entry.value; + } + + async set(key: string, result: TaskResult, ttl?: number): Promise { + const expiresAt = ttl !== undefined ? Date.now() + ttl : undefined; + this.cache.set(key, { value: result, expiresAt }); + } + + async delete(key: string): Promise { + this.cache.delete(key); + } +} diff --git a/tests/TaskRunnerCaching.test.ts b/tests/TaskRunnerCaching.test.ts new file mode 100644 index 0000000..c5900fb --- /dev/null +++ b/tests/TaskRunnerCaching.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest"; +import { TaskRunner } from "../src/TaskRunner.js"; +import { MemoryCacheProvider } from "../src/utils/MemoryCacheProvider.js"; +import { TaskStep } from "../src/TaskStep.js"; + +describe("TaskRunner Caching Integration", () => { + it("should cache successful task execution and restore context", async () => { + type TestContext = { + counter: number; + restored: boolean; + }; + + const context: TestContext = { counter: 0, restored: false }; + const runner = new TaskRunner(context); + const cacheProvider = new MemoryCacheProvider(); + runner.setCacheProvider(cacheProvider); + + const runMock = vi.fn().mockImplementation(async (ctx: TestContext) => { + ctx.counter++; + return { status: "success", data: "computed" }; + }); + + const step: TaskStep = { + name: "cached-task", + run: runMock, + cache: { + key: () => "static-key", + restore: (ctx) => { + ctx.restored = true; + }, + }, + }; + + // First execution: cache miss + const results1 = await runner.execute([step]); + expect(results1.get("cached-task")?.status).toBe("success"); + expect(results1.get("cached-task")?.data).toBe("computed"); + expect(runMock).toHaveBeenCalledTimes(1); + expect(context.counter).toBe(1); + expect(context.restored).toBe(false); + + // Reset restored flag just in case + context.restored = false; + + // Second execution: cache hit + const results2 = await runner.execute([step]); + expect(results2.get("cached-task")?.status).toBe("skipped"); // Or cached + expect(results2.get("cached-task")?.data).toBe("computed"); + expect(runMock).toHaveBeenCalledTimes(1); // Not called again + expect(context.counter).toBe(1); // Not incremented + expect(context.restored).toBe(true); // Restored was called + }); + + it("should not cache if step has no cache configuration", async () => { + type TestContext = { counter: number }; + const context: TestContext = { counter: 0 }; + const runner = new TaskRunner(context); + const cacheProvider = new MemoryCacheProvider(); + runner.setCacheProvider(cacheProvider); + + const runMock = vi.fn().mockImplementation(async (ctx: TestContext) => { + ctx.counter++; + return { status: "success", data: "computed" }; + }); + + const step: TaskStep = { + name: "uncached-task", + run: runMock, + }; + + await runner.execute([step]); + await runner.execute([step]); + + expect(runMock).toHaveBeenCalledTimes(2); + expect(context.counter).toBe(2); + }); +}); diff --git a/tests/strategies/CachingExecutionStrategy.test.ts b/tests/strategies/CachingExecutionStrategy.test.ts new file mode 100644 index 0000000..08daf9d --- /dev/null +++ b/tests/strategies/CachingExecutionStrategy.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CachingExecutionStrategy } from "../../src/strategies/CachingExecutionStrategy.js"; +import { IExecutionStrategy } from "../../src/strategies/IExecutionStrategy.js"; +import { ICacheProvider } from "../../src/contracts/ICacheProvider.js"; +import { TaskStep } from "../../src/TaskStep.js"; +import { TaskResult } from "../../src/TaskResult.js"; + +describe("CachingExecutionStrategy", () => { + let mockInnerStrategy: IExecutionStrategy; + let mockCacheProvider: ICacheProvider; + let strategy: CachingExecutionStrategy; + + beforeEach(() => { + mockInnerStrategy = { + execute: vi.fn(), + }; + mockCacheProvider = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }; + strategy = new CachingExecutionStrategy(mockInnerStrategy, mockCacheProvider); + }); + + it("should bypass caching if step.cache is not configured", async () => { + const step: TaskStep = { + name: "task1", + run: vi.fn(), + }; + const context = { data: 1 }; + const result: TaskResult = { status: "success" }; + + vi.mocked(mockInnerStrategy.execute).mockResolvedValue(result); + + const out = await strategy.execute(step, context); + + expect(mockInnerStrategy.execute).toHaveBeenCalledWith(step, context, undefined); + expect(mockCacheProvider.get).not.toHaveBeenCalled(); + expect(out).toBe(result); + }); + + it("should return cached result and skip execution on cache hit", async () => { + const step: TaskStep = { + name: "task1", + run: vi.fn(), + cache: { + key: () => "my-key", + }, + }; + const context = { data: 1 }; + const cachedResult: TaskResult = { status: "success", data: "cached-data" }; + + vi.mocked(mockCacheProvider.get).mockResolvedValue(cachedResult); + + const out = await strategy.execute(step, context); + + expect(mockCacheProvider.get).toHaveBeenCalledWith("my-key"); + expect(mockInnerStrategy.execute).not.toHaveBeenCalled(); + expect(out).toEqual({ ...cachedResult, status: "skipped" }); + }); + + it("should call restore function on cache hit if provided", async () => { + const restoreMock = vi.fn(); + const step: TaskStep = { + name: "task1", + run: vi.fn(), + cache: { + key: () => "my-key", + restore: restoreMock, + }, + }; + const context = { data: 1 }; + const cachedResult: TaskResult = { status: "success", data: "cached-data" }; + + vi.mocked(mockCacheProvider.get).mockResolvedValue(cachedResult); + + await strategy.execute(step, context); + + expect(restoreMock).toHaveBeenCalledWith(context, cachedResult); + }); + + it("should execute and store result on cache miss (success)", async () => { + const step: TaskStep = { + name: "task1", + run: vi.fn(), + cache: { + key: () => "my-key", + ttl: 5000, + }, + }; + const context = { data: 1 }; + const freshResult: TaskResult = { status: "success", data: "fresh" }; + + vi.mocked(mockCacheProvider.get).mockResolvedValue(undefined); + vi.mocked(mockInnerStrategy.execute).mockResolvedValue(freshResult); + + const out = await strategy.execute(step, context); + + expect(mockCacheProvider.get).toHaveBeenCalledWith("my-key"); + expect(mockInnerStrategy.execute).toHaveBeenCalledWith(step, context, undefined); + expect(mockCacheProvider.set).toHaveBeenCalledWith("my-key", freshResult, 5000); + expect(out).toBe(freshResult); + }); + + it("should execute but NOT store result on cache miss if execution fails", async () => { + const step: TaskStep = { + name: "task1", + run: vi.fn(), + cache: { + key: () => "my-key", + }, + }; + const context = { data: 1 }; + const failureResult: TaskResult = { status: "failure", error: "oops" }; + + vi.mocked(mockCacheProvider.get).mockResolvedValue(undefined); + vi.mocked(mockInnerStrategy.execute).mockResolvedValue(failureResult); + + const out = await strategy.execute(step, context); + + expect(mockCacheProvider.get).toHaveBeenCalledWith("my-key"); + expect(mockInnerStrategy.execute).toHaveBeenCalledWith(step, context, undefined); + expect(mockCacheProvider.set).not.toHaveBeenCalled(); + expect(out).toBe(failureResult); + }); +}); diff --git a/tests/utils/MemoryCacheProvider.test.ts b/tests/utils/MemoryCacheProvider.test.ts new file mode 100644 index 0000000..fc74b18 --- /dev/null +++ b/tests/utils/MemoryCacheProvider.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { MemoryCacheProvider } from "../../src/utils/MemoryCacheProvider.js"; +import { TaskResult } from "../../src/TaskResult.js"; + +describe("MemoryCacheProvider", () => { + it("should return undefined for a missing key", async () => { + const provider = new MemoryCacheProvider(); + const result = await provider.get("non-existent"); + expect(result).toBeUndefined(); + }); + + it("should store and retrieve a value", async () => { + const provider = new MemoryCacheProvider(); + const taskResult: TaskResult = { status: "success", data: "test" }; + + await provider.set("key1", taskResult); + const retrieved = await provider.get("key1"); + + expect(retrieved).toEqual(taskResult); + }); + + it("should overwrite an existing value", async () => { + const provider = new MemoryCacheProvider(); + const result1: TaskResult = { status: "success", data: "first" }; + const result2: TaskResult = { status: "success", data: "second" }; + + await provider.set("key1", result1); + await provider.set("key1", result2); + + const retrieved = await provider.get("key1"); + expect(retrieved).toEqual(result2); + }); + + it("should delete a value", async () => { + const provider = new MemoryCacheProvider(); + const taskResult: TaskResult = { status: "success", data: "test" }; + + await provider.set("key1", taskResult); + await provider.delete("key1"); + + const retrieved = await provider.get("key1"); + expect(retrieved).toBeUndefined(); + }); + + it("should handle TTL correctly", async () => { + vi.useFakeTimers(); + try { + const provider = new MemoryCacheProvider(); + const taskResult: TaskResult = { status: "success", data: "test" }; + + await provider.set("key1", taskResult, 1000); // 1 second TTL + + // Immediately available + let retrieved = await provider.get("key1"); + expect(retrieved).toEqual(taskResult); + + // Advance time by 500ms + vi.advanceTimersByTime(500); + retrieved = await provider.get("key1"); + expect(retrieved).toEqual(taskResult); + + // Advance time by another 501ms (past TTL) + vi.advanceTimersByTime(501); + retrieved = await provider.get("key1"); + expect(retrieved).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); +});