UNPKG

@pimzino/spec-workflow-mcp

Version:

MCP server for spec-driven development workflow with real-time web dashboard

251 lines 10.2 kB
import { EventEmitter } from 'events'; import { promises as fs } from 'fs'; import { join, isAbsolute, resolve } from 'path'; import chokidar from 'chokidar'; import { PathUtils } from '../core/path-utils.js'; export class ApprovalStorage extends EventEmitter { projectPath; // Make public so dashboard server can access it approvalsDir; watcher; constructor(projectPath) { super(); // Validate project path if (!projectPath || projectPath.trim() === '') { throw new Error('Project path cannot be empty'); } // Resolve to absolute path const resolvedPath = resolve(projectPath); // Prevent root directory usage which causes permission errors if (resolvedPath === '/' || resolvedPath === '\\' || resolvedPath.match(/^[A-Z]:\\?$/)) { throw new Error(`Invalid project path: ${resolvedPath}. Cannot use root directory for spec workflow.`); } this.projectPath = resolvedPath; this.approvalsDir = PathUtils.getApprovalsPath(resolvedPath); } async start() { // Create the approvals directory (empty) so watcher can establish properly await fs.mkdir(this.approvalsDir, { recursive: true }); // Set up file watcher for approval directory and all subdirectories // This will catch new directories and files created dynamically this.watcher = chokidar.watch(`${this.approvalsDir}/**/*.json`, { ignoreInitial: false, persistent: true, ignorePermissionErrors: true }); this.watcher.on('add', () => this.emit('approval-change')); this.watcher.on('change', () => this.emit('approval-change')); this.watcher.on('unlink', () => this.emit('approval-change')); } async stop() { if (this.watcher) { // Remove all listeners before closing to prevent memory leaks this.watcher.removeAllListeners(); await this.watcher.close(); this.watcher = undefined; } // Clean up EventEmitter listeners this.removeAllListeners(); } async createApproval(title, filePath, category, categoryName, type = 'document', metadata) { const id = this.generateId(); const approval = { id, title, filePath, type, status: 'pending', createdAt: new Date().toISOString(), metadata, category, categoryName }; // Create category directory if it doesn't exist const categoryDir = join(this.approvalsDir, categoryName); await fs.mkdir(categoryDir, { recursive: true }); const approvalFilePath = join(categoryDir, `${id}.json`); await fs.writeFile(approvalFilePath, JSON.stringify(approval, null, 2), 'utf-8'); return id; } async getApproval(id) { // Search across all categories and names try { const approvalPath = await this.findApprovalPath(id); if (!approvalPath) return null; const content = await fs.readFile(approvalPath, 'utf-8'); return JSON.parse(content); } catch { return null; } } async findApprovalPath(id) { // Search in approvals directory directly (no 'specs' subfolder) try { const categoryNames = await fs.readdir(this.approvalsDir, { withFileTypes: true }); for (const categoryName of categoryNames) { if (categoryName.isDirectory()) { const approvalPath = join(this.approvalsDir, categoryName.name, `${id}.json`); try { await fs.access(approvalPath); return approvalPath; } catch { // File doesn't exist in this location, continue searching } } } } catch { // Approvals directory doesn't exist } return null; } async updateApproval(id, status, response, annotations, comments) { const approval = await this.getApproval(id); if (!approval) { throw new Error(`Approval ${id} not found`); } approval.status = status; approval.response = response; approval.annotations = annotations; approval.respondedAt = new Date().toISOString(); if (comments) { approval.comments = comments; } const filePath = await this.findApprovalPath(id); if (!filePath) { throw new Error(`Approval ${id} file not found`); } await fs.writeFile(filePath, JSON.stringify(approval, null, 2), 'utf-8'); } async createRevision(originalId, newContent, reason) { const originalApproval = await this.getApproval(originalId); if (!originalApproval) { throw new Error(`Original approval ${originalId} not found`); } if (!originalApproval.filePath) { throw new Error(`Approval ${originalId} has no file path for revision`); } // Read the current file content for revision history const filePath = isAbsolute(originalApproval.filePath) ? originalApproval.filePath : join(this.projectPath, originalApproval.filePath); let currentContent = ''; try { currentContent = await fs.readFile(filePath, 'utf-8'); } catch (error) { // Could not read file for revision history } // Add to revision history if (!originalApproval.revisionHistory) { originalApproval.revisionHistory = []; } const version = (originalApproval.revisionHistory.length || 0) + 1; originalApproval.revisionHistory.push({ version: version - 1, content: currentContent, timestamp: originalApproval.respondedAt || originalApproval.createdAt, reason: reason }); // Write the new content to the file await fs.writeFile(filePath, newContent, 'utf-8'); // Reset approval status for re-review originalApproval.status = 'pending'; originalApproval.response = undefined; originalApproval.annotations = undefined; originalApproval.comments = undefined; originalApproval.respondedAt = undefined; const approvalFilePath = await this.findApprovalPath(originalId); if (!approvalFilePath) { throw new Error(`Approval ${originalId} file not found`); } await fs.writeFile(approvalFilePath, JSON.stringify(originalApproval, null, 2), 'utf-8'); return originalId; } async getAllPendingApprovals() { const allApprovals = await this.getAllApprovals(); return allApprovals.filter(approval => approval.status === 'pending'); } async getAllApprovals() { try { const approvals = []; try { const categoryNames = await fs.readdir(this.approvalsDir, { withFileTypes: true }); for (const categoryName of categoryNames) { if (categoryName.isDirectory()) { const categoryPath = join(this.approvalsDir, categoryName.name); try { const approvalFiles = await fs.readdir(categoryPath); for (const file of approvalFiles) { if (file.endsWith('.json')) { try { const content = await fs.readFile(join(categoryPath, file), 'utf-8'); const approval = JSON.parse(content); approvals.push(approval); } catch (error) { // Error reading approval file } } } } catch (error) { // Error reading category directory } } } } catch { // Approvals directory doesn't exist } // Sort by creation date (newest first) return approvals.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } catch { return []; } } async deleteApproval(id) { try { const approvalPath = await this.findApprovalPath(id); if (!approvalPath) return false; await fs.unlink(approvalPath); return true; } catch { return false; } } async cleanupOldApprovals(maxAgeDays = 7) { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); try { const files = await fs.readdir(this.approvalsDir); for (const file of files) { if (file.endsWith('.json')) { try { const content = await fs.readFile(join(this.approvalsDir, file), 'utf-8'); const approval = JSON.parse(content); const createdAt = new Date(approval.createdAt); if (createdAt < cutoffDate && approval.status !== 'pending') { await fs.unlink(join(this.approvalsDir, file)); } } catch (error) { // Error processing approval file } } } } catch (error) { // Error cleaning up old approvals } } generateId() { return `approval_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } } //# sourceMappingURL=approval-storage.js.map