UNPKG

mcp-adr-analysis-server

Version:

MCP server for analyzing Architectural Decision Records and project architecture

248 lines 8.03 kB
/** * Task Persistence for MCP Tasks Integration * * Provides file-based persistence for tasks to survive server restarts. * Uses JSON files stored in a configurable cache directory. * * Implements ADR-018: MCP Tasks Integration Strategy * * @experimental MCP Tasks is an experimental feature in the MCP specification */ import * as fs from 'fs/promises'; import * as path from 'path'; import { createComponentLogger } from './enhanced-logging.js'; const logger = createComponentLogger('TaskPersistence'); const CURRENT_VERSION = 1; const DEFAULT_CONFIG = { cacheDir: '.mcp-adr-cache/tasks', enabled: true, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days writeDelay: 1000, }; /** * Task Persistence Manager * * Handles saving and loading tasks to/from disk for durability across * server restarts. Uses debounced writes to avoid excessive disk I/O. */ export class TaskPersistence { config; writeTimer = null; pendingWrite = null; initialized = false; constructor(config = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Initialize persistence - create cache directory if needed */ async initialize() { if (!this.config.enabled) { logger.info('Task persistence disabled'); return; } try { await fs.mkdir(this.config.cacheDir, { recursive: true }); this.initialized = true; logger.info('Task persistence initialized', { cacheDir: this.config.cacheDir }); } catch (error) { logger.error('Failed to initialize task persistence', error instanceof Error ? error : undefined, { cacheDir: this.config.cacheDir, }); // Don't throw - persistence is optional this.config.enabled = false; } } /** * Get the path to the tasks file */ getTasksFilePath() { return path.join(this.config.cacheDir, 'tasks.json'); } /** * Load persisted tasks from disk */ async load() { const tasks = new Map(); const progress = new Map(); if (!this.config.enabled || !this.initialized) { return { tasks, progress }; } const filePath = this.getTasksFilePath(); try { const data = await fs.readFile(filePath, 'utf-8'); const parsed = JSON.parse(data); // Version check if (parsed.version !== CURRENT_VERSION) { logger.warn('Task persistence version mismatch, migrating', { savedVersion: parsed.version, currentVersion: CURRENT_VERSION, }); // For now, just load what we can } const now = Date.now(); let expiredCount = 0; for (const task of parsed.tasks) { // Skip expired tasks const taskAge = now - new Date(task.lastUpdatedAt).getTime(); if (taskAge > this.config.maxAge) { expiredCount++; continue; } tasks.set(task.taskId, task); const taskProgress = parsed.progress[task.taskId]; if (taskProgress !== undefined) { progress.set(task.taskId, taskProgress); } } logger.info('Tasks loaded from persistence', { loadedCount: tasks.size, expiredCount, lastSaved: parsed.lastSaved, }); } catch (error) { if (error.code === 'ENOENT') { logger.debug('No persisted tasks file found'); } else { logger.error('Failed to load persisted tasks', error instanceof Error ? error : undefined); } } return { tasks, progress }; } /** * Save tasks to disk (debounced) */ async save(tasks, progress) { if (!this.config.enabled || !this.initialized) { return; } // Prepare data for persistence const data = { version: CURRENT_VERSION, lastSaved: new Date().toISOString(), tasks: Array.from(tasks.values()), progress: Object.fromEntries(progress), }; // Debounce writes this.pendingWrite = data; if (this.writeTimer) { return; // Already scheduled } this.writeTimer = setTimeout(async () => { this.writeTimer = null; if (this.pendingWrite) { await this.writeToFile(this.pendingWrite); this.pendingWrite = null; } }, this.config.writeDelay); } /** * Force immediate save (for shutdown) */ async saveImmediate(tasks, progress) { if (!this.config.enabled || !this.initialized) { return; } // Cancel pending debounced write if (this.writeTimer) { clearTimeout(this.writeTimer); this.writeTimer = null; } this.pendingWrite = null; const data = { version: CURRENT_VERSION, lastSaved: new Date().toISOString(), tasks: Array.from(tasks.values()), progress: Object.fromEntries(progress), }; await this.writeToFile(data); } /** * Write data to file */ async writeToFile(data) { const filePath = this.getTasksFilePath(); const tempPath = `${filePath}.tmp`; try { // Write to temp file first await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf-8'); // Atomic rename await fs.rename(tempPath, filePath); logger.debug('Tasks persisted to disk', { taskCount: data.tasks.length }); } catch (error) { logger.error('Failed to persist tasks', error instanceof Error ? error : undefined); // Try to clean up temp file try { await fs.unlink(tempPath); } catch { // Ignore cleanup errors } } } /** * Delete persisted task data */ async clear() { if (!this.config.enabled) { return; } const filePath = this.getTasksFilePath(); try { await fs.unlink(filePath); logger.info('Cleared persisted tasks'); } catch (error) { if (error.code !== 'ENOENT') { logger.error('Failed to clear persisted tasks', error instanceof Error ? error : undefined); } } } /** * Cleanup old task files */ async cleanup() { if (!this.config.enabled || !this.initialized) { return 0; } // Load current tasks, which will filter out expired ones const { tasks, progress } = await this.load(); // Re-save to remove expired tasks from file if (tasks.size > 0) { await this.saveImmediate(tasks, progress); } else { await this.clear(); } return 0; // We handled cleanup during load } /** * Shutdown persistence - flush pending writes */ async shutdown() { if (this.writeTimer) { clearTimeout(this.writeTimer); this.writeTimer = null; } if (this.pendingWrite) { await this.writeToFile(this.pendingWrite); this.pendingWrite = null; } logger.info('Task persistence shut down'); } /** * Check if persistence is enabled */ isEnabled() { return this.config.enabled && this.initialized; } } // Factory function for creating persistence with custom config export function createTaskPersistence(config) { return new TaskPersistence(config); } //# sourceMappingURL=task-persistence.js.map