mcp-adr-analysis-server
Version:
MCP server for analyzing Architectural Decision Records and project architecture
248 lines • 8.03 kB
JavaScript
/**
* 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