diff --git a/ccw/src/core/cache-manager.ts b/ccw/src/core/cache-manager.ts new file mode 100644 index 00000000..bae378a8 --- /dev/null +++ b/ccw/src/core/cache-manager.ts @@ -0,0 +1,277 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs'; +import { join, dirname } from 'path'; + +interface CacheEntry { + data: T; + timestamp: number; + fileHashes: Map; // file path -> mtime + ttl?: number; +} + +interface CacheOptions { + ttl?: number; // Time-to-live in milliseconds (default: 5 minutes) + cacheDir?: string; // Cache directory (default: .ccw-cache) +} + +/** + * CacheManager class for storing and retrieving dashboard data + * Tracks file modification times to detect changes and invalidate cache + */ +export class CacheManager { + private cacheFile: string; + private ttl: number; + private cacheDir: string; + + /** + * Create a new CacheManager instance + * @param cacheKey - Unique identifier for this cache (e.g., 'dashboard-data') + * @param options - Cache configuration options + */ + constructor(cacheKey: string, options: CacheOptions = {}) { + this.ttl = options.ttl || 5 * 60 * 1000; // Default: 5 minutes + this.cacheDir = options.cacheDir || '.ccw-cache'; + this.cacheFile = join(this.cacheDir, `${cacheKey}.json`); + } + + /** + * Get cached data if valid, otherwise return null + * @param watchPaths - Array of file/directory paths to check for modifications + * @returns Cached data or null if invalid/expired + */ + get(watchPaths: string[] = []): T | null { + if (!existsSync(this.cacheFile)) { + return null; + } + + try { + const content = readFileSync(this.cacheFile, 'utf8'); + const entry: CacheEntry = JSON.parse(content, (key, value) => { + // Revive Map objects from JSON + if (key === 'fileHashes' && value && typeof value === 'object') { + return new Map(Object.entries(value)); + } + return value; + }); + + // Check TTL expiration + if (this.ttl > 0) { + const age = Date.now() - entry.timestamp; + if (age > this.ttl) { + return null; + } + } + + // Check if any watched files have changed + if (watchPaths.length > 0) { + const currentHashes = this.computeFileHashes(watchPaths); + if (!this.hashesMatch(entry.fileHashes, currentHashes)) { + return null; + } + } + + return entry.data; + } catch (err) { + // If cache file is corrupted or unreadable, treat as invalid + console.warn(`Cache read error for ${this.cacheFile}:`, (err as Error).message); + return null; + } + } + + /** + * Store data in cache with current timestamp and file hashes + * @param data - Data to cache + * @param watchPaths - Array of file/directory paths to track + */ + set(data: T, watchPaths: string[] = []): void { + try { + // Ensure cache directory exists + if (!existsSync(this.cacheDir)) { + mkdirSync(this.cacheDir, { recursive: true }); + } + + const entry: CacheEntry = { + data, + timestamp: Date.now(), + fileHashes: this.computeFileHashes(watchPaths), + ttl: this.ttl + }; + + // Convert Map to plain object for JSON serialization + const serializable = { + ...entry, + fileHashes: Object.fromEntries(entry.fileHashes) + }; + + writeFileSync(this.cacheFile, JSON.stringify(serializable, null, 2), 'utf8'); + } catch (err) { + console.warn(`Cache write error for ${this.cacheFile}:`, (err as Error).message); + } + } + + /** + * Invalidate (delete) the cache + */ + invalidate(): void { + try { + if (existsSync(this.cacheFile)) { + const fs = require('fs'); + fs.unlinkSync(this.cacheFile); + } + } catch (err) { + console.warn(`Cache invalidation error for ${this.cacheFile}:`, (err as Error).message); + } + } + + /** + * Check if cache is valid without retrieving data + * @param watchPaths - Array of file/directory paths to check + * @returns True if cache exists and is valid + */ + isValid(watchPaths: string[] = []): boolean { + return this.get(watchPaths) !== null; + } + + /** + * Compute file modification times for all watched paths + * @param watchPaths - Array of file/directory paths + * @returns Map of path to mtime + */ + private computeFileHashes(watchPaths: string[]): Map { + const hashes = new Map(); + + for (const path of watchPaths) { + try { + if (!existsSync(path)) { + continue; + } + + const stats = statSync(path); + + if (stats.isDirectory()) { + // For directories, use directory mtime (detects file additions/deletions) + hashes.set(path, stats.mtimeMs); + + // Also recursively scan for workflow session files + this.scanDirectory(path, hashes); + } else { + // For files, use file mtime + hashes.set(path, stats.mtimeMs); + } + } catch (err) { + // Skip paths that can't be accessed + console.warn(`Cannot access path ${path}:`, (err as Error).message); + } + } + + return hashes; + } + + /** + * Recursively scan directory for important files + * @param dirPath - Directory to scan + * @param hashes - Map to store file hashes + * @param depth - Current recursion depth (max 3) + */ + private scanDirectory(dirPath: string, hashes: Map, depth: number = 0): void { + if (depth > 3) return; // Limit recursion depth + + try { + const fs = require('fs'); + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + + if (entry.isDirectory()) { + // Track important directories + if (entry.name === '.task' || entry.name === '.review' || entry.name === '.summaries') { + const stats = statSync(fullPath); + hashes.set(fullPath, stats.mtimeMs); + this.scanDirectory(fullPath, hashes, depth + 1); + } else if (entry.name.startsWith('WFS-')) { + // Scan WFS session directories + const stats = statSync(fullPath); + hashes.set(fullPath, stats.mtimeMs); + this.scanDirectory(fullPath, hashes, depth + 1); + } + } else if (entry.isFile()) { + // Track important files + if ( + entry.name.endsWith('.json') || + entry.name === 'IMPL_PLAN.md' || + entry.name === 'TODO_LIST.md' || + entry.name === 'workflow-session.json' + ) { + const stats = statSync(fullPath); + hashes.set(fullPath, stats.mtimeMs); + } + } + } + } catch (err) { + // Skip directories that can't be read + console.warn(`Cannot scan directory ${dirPath}:`, (err as Error).message); + } + } + + /** + * Compare two file hash maps + * @param oldHashes - Previous hashes + * @param newHashes - Current hashes + * @returns True if hashes match (no changes) + */ + private hashesMatch(oldHashes: Map, newHashes: Map): boolean { + // Check if any files were added or removed + if (oldHashes.size !== newHashes.size) { + return false; + } + + // Check if any file mtimes changed + const entries = Array.from(oldHashes.entries()); + for (let i = 0; i < entries.length; i++) { + const path = entries[i][0]; + const oldMtime = entries[i][1]; + const newMtime = newHashes.get(path); + if (newMtime === undefined || newMtime !== oldMtime) { + return false; + } + } + + return true; + } + + /** + * Get cache statistics + * @returns Cache info object + */ + getStats(): { exists: boolean; age?: number; fileCount?: number; size?: number } { + if (!existsSync(this.cacheFile)) { + return { exists: false }; + } + + try { + const stats = statSync(this.cacheFile); + const content = readFileSync(this.cacheFile, 'utf8'); + const entry = JSON.parse(content); + + return { + exists: true, + age: Date.now() - entry.timestamp, + fileCount: Object.keys(entry.fileHashes || {}).length, + size: stats.size + }; + } catch { + return { exists: false }; + } + } +} + +/** + * Create a cache manager for dashboard data + * @param workflowDir - Path to .workflow directory + * @param ttl - Optional TTL in milliseconds + * @returns CacheManager instance + */ +export function createDashboardCache(workflowDir: string, ttl?: number): CacheManager { + const cacheDir = join(workflowDir, '.ccw-cache'); + return new CacheManager('dashboard-data', { cacheDir, ttl }); +} diff --git a/ccw/src/core/data-aggregator.ts b/ccw/src/core/data-aggregator.ts index 287aa8fe..1d15c703 100644 --- a/ccw/src/core/data-aggregator.ts +++ b/ccw/src/core/data-aggregator.ts @@ -2,6 +2,7 @@ import { glob } from 'glob'; import { readFileSync, existsSync } from 'fs'; import { join, basename } from 'path'; import { scanLiteTasks } from './lite-scanner.js'; +import { createDashboardCache } from './cache-manager.js'; interface SessionData { session_id: string; @@ -143,12 +144,33 @@ interface ProjectOverview { } /** - * Aggregate all data for dashboard rendering + * Aggregate all data for dashboard rendering (with caching) * @param sessions - Scanned sessions from session-scanner * @param workflowDir - Path to .workflow directory * @returns Aggregated dashboard data */ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: string): Promise { + // Initialize cache manager + const cache = createDashboardCache(workflowDir); + + // Prepare paths to watch for changes + const watchPaths = [ + join(workflowDir, 'active'), + join(workflowDir, 'archives'), + join(workflowDir, 'project.json'), + ...sessions.active.map(s => s.path), + ...sessions.archived.map(s => s.path) + ]; + + // Check cache first + const cachedData = cache.get(watchPaths); + if (cachedData !== null) { + console.log('Using cached dashboard data'); + return cachedData; + } + + console.log('Cache miss - regenerating dashboard data'); + const data: DashboardData = { generatedAt: new Date().toISOString(), activeSessions: [], @@ -212,6 +234,9 @@ export async function aggregateData(sessions: ScanSessionsResult, workflowDir: s console.error('Error loading project overview:', (err as Error).message); } + // Store in cache before returning + cache.set(data, watchPaths); + return data; } diff --git a/ccw/src/core/session-scanner.ts b/ccw/src/core/session-scanner.ts index c1bae691..19760798 100644 --- a/ccw/src/core/session-scanner.ts +++ b/ccw/src/core/session-scanner.ts @@ -1,7 +1,16 @@ import { glob } from 'glob'; -import { readFileSync, existsSync, statSync, readdirSync } from 'fs'; +import { readFile, readdir, stat, access } from 'fs/promises'; +import { constants } from 'fs'; import { join, basename } from 'path'; import type { SessionMetadata, SessionType } from '../types/session.js'; +async function fileExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} interface SessionData extends SessionMetadata { path: string; @@ -28,46 +37,54 @@ export async function scanSessions(workflowDir: string): Promise { const sessionPath = join(activeDir, sessionName); - const sessionData = readSessionData(sessionPath); + const sessionData = await readSessionData(sessionPath); if (sessionData) { - result.active.push({ + // Check for review data + if (await fileExists(join(sessionPath, '.review'))) { + result.hasReviewData = true; + } + return { ...sessionData, path: sessionPath, isActive: true - }); - // Check for review data - if (existsSync(join(sessionPath, '.review'))) { - result.hasReviewData = true; - } + }; } - } + return null; + }); + + const activeSessionData = (await Promise.all(activeSessionDataPromises)).filter((s): s is SessionData => s !== null); + result.active.push(...activeSessionData); } - // Scan archived sessions + // Scan archived sessions const archivesDir = join(workflowDir, 'archives'); - if (existsSync(archivesDir)) { + if (await fileExists(archivesDir)) { const archivedSessions = await findWfsSessions(archivesDir); - for (const sessionName of archivedSessions) { + const archivedSessionDataPromises = archivedSessions.map(async (sessionName) => { const sessionPath = join(archivesDir, sessionName); - const sessionData = readSessionData(sessionPath); + const sessionData = await readSessionData(sessionPath); if (sessionData) { - result.archived.push({ + return { ...sessionData, path: sessionPath, isActive: false - }); + }; } - } + return null; + }); + + const archivedSessionData = (await Promise.all(archivedSessionDataPromises)).filter((s): s is SessionData => s !== null); + result.archived.push(...archivedSessionData); } // Sort by creation date (newest first) @@ -98,7 +115,7 @@ async function findWfsSessions(dir: string): Promise { } catch { // Fallback: manual directory listing try { - const entries = readdirSync(dir, { withFileTypes: true }); + const entries = await readdir(dir, { withFileTypes: true }); return entries .filter(e => e.isDirectory() && e.name.startsWith('WFS-')) .map(e => e.name); @@ -162,13 +179,13 @@ function inferTypeFromName(sessionName: string): SessionType { * @param sessionPath - Path to session directory * @returns Session data object or null if invalid */ -function readSessionData(sessionPath: string): SessionData | null { +async function readSessionData(sessionPath: string): Promise { const sessionFile = join(sessionPath, 'workflow-session.json'); const sessionName = basename(sessionPath); - if (existsSync(sessionFile)) { + if (await fileExists(sessionFile)) { try { - const data = JSON.parse(readFileSync(sessionFile, 'utf8')) as Record; + const data = JSON.parse(await readFile(sessionFile, 'utf8')) as Record; // Multi-level type detection: JSON type > workflow_type > infer from name let type = (data.type as SessionType) || (data.workflow_type as SessionType) || inferTypeFromName(sessionName); @@ -201,7 +218,7 @@ function readSessionData(sessionPath: string): SessionData | null { const inferredType = inferTypeFromName(sessionName); try { - const stats = statSync(sessionPath); + const stats = await stat(sessionPath); const createdAt = timestampFromName || stats.birthtime.toISOString(); return { id: sessionName, @@ -242,9 +259,9 @@ function readSessionData(sessionPath: string): SessionData | null { * @param sessionPath - Path to session directory * @returns True if review data exists */ -export function hasReviewData(sessionPath: string): boolean { +export async function hasReviewData(sessionPath: string): Promise { const reviewDir = join(sessionPath, '.review'); - return existsSync(reviewDir); + return await fileExists(reviewDir); } /** @@ -254,7 +271,7 @@ export function hasReviewData(sessionPath: string): boolean { */ export async function getTaskFiles(sessionPath: string): Promise { const taskDir = join(sessionPath, '.task'); - if (!existsSync(taskDir)) { + if (!await fileExists(taskDir)) { return []; } diff --git a/ccw/src/index.ts b/ccw/src/index.ts index 5881089e..a9f5de6a 100644 --- a/ccw/src/index.ts +++ b/ccw/src/index.ts @@ -7,3 +7,4 @@ export { run } from './cli.js'; export { scanSessions } from './core/session-scanner.js'; export { aggregateData } from './core/data-aggregator.js'; export { generateDashboard } from './core/dashboard-generator.js'; +export { CacheManager, createDashboardCache } from './core/cache-manager.js'; diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index 5e674ebb..cbaf1136 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -79,6 +79,46 @@ interface ToolAvailability { path: string | null; } +// Tool availability cache with TTL +interface CachedToolAvailability { + result: ToolAvailability; + timestamp: number; +} + +// Cache storage: Map +const toolAvailabilityCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Check if cache entry is still valid + */ +function isCacheValid(cached: CachedToolAvailability): boolean { + return Date.now() - cached.timestamp < CACHE_TTL_MS; +} + +/** + * Clear expired cache entries + */ +function clearExpiredCache(): void { + const now = Date.now(); + const entriesToDelete: string[] = []; + + toolAvailabilityCache.forEach((cached, tool) => { + if (now - cached.timestamp >= CACHE_TTL_MS) { + entriesToDelete.push(tool); + } + }); + + entriesToDelete.forEach(tool => toolAvailabilityCache.delete(tool)); +} + +/** + * Clear all cache entries (useful for testing or forced refresh) + */ +export function clearToolCache(): void { + toolAvailabilityCache.clear(); +} + // Single turn in a conversation interface ConversationTurn { turn: number; @@ -107,6 +147,7 @@ interface ConversationRecord { turn_count: number; latest_status: 'success' | 'error' | 'timeout'; turns: ConversationTurn[]; + parent_execution_id?: string; // For fork/retry scenarios } // Legacy single execution record (for backward compatibility) @@ -151,9 +192,19 @@ interface ExecutionOutput { } /** - * Check if a CLI tool is available + * Check if a CLI tool is available (with caching) */ async function checkToolAvailability(tool: string): Promise { + // Check cache first + const cached = toolAvailabilityCache.get(tool); + if (cached && isCacheValid(cached)) { + return cached.result; + } + + // Clear expired entries periodically + clearExpiredCache(); + + // Perform actual check return new Promise((resolve) => { const isWindows = process.platform === 'win32'; const command = isWindows ? 'where' : 'which'; @@ -167,21 +218,43 @@ async function checkToolAvailability(tool: string): Promise { child.stdout!.on('data', (data) => { stdout += data.toString(); }); child.on('close', (code) => { - if (code === 0 && stdout.trim()) { - resolve({ available: true, path: stdout.trim().split('\n')[0] }); - } else { - resolve({ available: false, path: null }); - } + const result: ToolAvailability = code === 0 && stdout.trim() + ? { available: true, path: stdout.trim().split('\n')[0] } + : { available: false, path: null }; + + // Cache the result + toolAvailabilityCache.set(tool, { + result, + timestamp: Date.now() + }); + + resolve(result); }); child.on('error', () => { - resolve({ available: false, path: null }); + const result: ToolAvailability = { available: false, path: null }; + + // Cache negative results too + toolAvailabilityCache.set(tool, { + result, + timestamp: Date.now() + }); + + resolve(result); }); // Timeout after 5 seconds setTimeout(() => { child.kill(); - resolve({ available: false, path: null }); + const result: ToolAvailability = { available: false, path: null }; + + // Cache timeout results + toolAvailabilityCache.set(tool, { + result, + timestamp: Date.now() + }); + + resolve(result); }, 5000); }); } @@ -503,7 +576,7 @@ async function executeCliTool( throw new Error(`Invalid params: ${parsed.error.message}`); } - const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category } = parsed.data; + const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId } = parsed.data; // Determine working directory early (needed for conversation lookup) const workingDir = cd || process.cwd(); @@ -858,7 +931,8 @@ async function executeCliTool( total_duration_ms: duration, turn_count: 1, latest_status: status, - turns: [newTurn] + turns: [newTurn], + parent_execution_id: parentExecutionId }; // Try to save conversation to history try {