UNPKG

@ttaqt/novel-workflow-mcp

Version:

MCP server for AI-assisted novel writing workflow with real-time web dashboard

498 lines 19.8 kB
import { EventEmitter } from 'events'; import { promises as fs } from 'fs'; import { join, isAbsolute, resolve, basename } 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'); // Capture initial snapshot try { await this.captureSnapshot(id, 'initial'); } catch (error) { // Silently ignore snapshot errors to avoid polluting MCP responses // Error is logged internally but not to console } 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`); } // Capture snapshot before status change for certain transitions if (status === 'needs-revision') { try { await this.captureSnapshot(id, 'revision_requested'); } catch (error) { // Silently ignore snapshot errors } } else if (status === 'approved') { try { await this.captureSnapshot(id, 'approved'); } catch (error) { // Silently ignore snapshot errors } } 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; // Delete the approval file await fs.unlink(approvalPath); // NOTE: We DO NOT delete snapshots since they are now shared across approvals for the same file // Snapshots are stored in .snapshots/{filename}/ and should persist across approval cycles 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 } } // Snapshot Management Methods async captureSnapshot(approvalId, trigger) { const approval = await this.getApproval(approvalId); if (!approval || !approval.filePath) { throw new Error(`Approval ${approvalId} not found or has no file path`); } // Read current file content const filePath = isAbsolute(approval.filePath) ? approval.filePath : join(this.projectPath, approval.filePath); let content; let stats; try { content = await fs.readFile(filePath, 'utf-8'); stats = await fs.stat(filePath); } catch (error) { throw new Error(`Failed to read file for snapshot: ${error instanceof Error ? error.message : String(error)}`); } // Create file-based snapshots directory const categoryDir = join(this.approvalsDir, approval.categoryName || 'default'); const snapshotsDir = join(categoryDir, '.snapshots', basename(approval.filePath)); await fs.mkdir(snapshotsDir, { recursive: true }); // Load or create metadata const metadataPath = join(snapshotsDir, 'metadata.json'); let metadata; try { const metadataContent = await fs.readFile(metadataPath, 'utf-8'); metadata = JSON.parse(metadataContent); } catch { metadata = { filePath: approval.filePath, currentVersion: 0, snapshots: [] }; } // Check for duplicate initial snapshots if (trigger === 'initial') { const existingInitial = metadata.snapshots.find(s => s.trigger === 'initial'); if (existingInitial) { // Initial snapshot already exists, skip creation silently return; } } // Create new snapshot const version = metadata.currentVersion + 1; const snapshotId = `snapshot-${version.toString().padStart(3, '0')}`; const timestamp = new Date().toISOString(); const snapshot = { id: this.generateSnapshotId(), approvalId, approvalTitle: approval.title, version, timestamp, trigger, status: approval.status, content, fileStats: { size: stats.size, lines: content.split('\n').length, lastModified: stats.mtime.toISOString() }, comments: approval.comments || [], annotations: approval.annotations || undefined }; // Write snapshot to disk const snapshotPath = join(snapshotsDir, `${snapshotId}.json`); await fs.writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf-8'); // Update metadata metadata.currentVersion = version; metadata.snapshots.push({ version, filename: `${snapshotId}.json`, timestamp, trigger, approvalId, approvalTitle: approval.title }); await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); } async getSnapshots(approvalId) { const approval = await this.getApproval(approvalId); if (!approval || !approval.filePath) return []; // Get snapshots based on file path, not approval ID const categoryDir = join(this.approvalsDir, approval.categoryName || 'default'); const snapshotsDir = join(categoryDir, '.snapshots', basename(approval.filePath)); const metadataPath = join(snapshotsDir, 'metadata.json'); try { const metadataContent = await fs.readFile(metadataPath, 'utf-8'); const metadata = JSON.parse(metadataContent); const snapshots = []; for (const snapMeta of metadata.snapshots) { const snapPath = join(snapshotsDir, snapMeta.filename); const snapshotContent = await fs.readFile(snapPath, 'utf-8'); const snapshot = JSON.parse(snapshotContent); snapshots.push(snapshot); } return snapshots.sort((a, b) => a.version - b.version); } catch { return []; } } async getSnapshot(approvalId, version) { const snapshots = await this.getSnapshots(approvalId); return snapshots.find(s => s.version === version) || null; } async getCurrentFileContent(approvalId) { const approval = await this.getApproval(approvalId); if (!approval || !approval.filePath) return null; const filePath = isAbsolute(approval.filePath) ? approval.filePath : join(this.projectPath, approval.filePath); try { return await fs.readFile(filePath, 'utf-8'); } catch { return null; } } async compareSnapshots(approvalId, fromVersion, toVersion) { let fromContent; let toContent; if (fromVersion === 0) { fromContent = ''; } else { const fromSnapshot = await this.getSnapshot(approvalId, fromVersion); if (!fromSnapshot) { throw new Error(`Snapshot version ${fromVersion} not found`); } fromContent = fromSnapshot.content; } if (toVersion === 'current') { const currentContent = await this.getCurrentFileContent(approvalId); if (currentContent === null) { throw new Error(`Could not read current file content for approval ${approvalId}`); } toContent = currentContent; } else { const toSnapshot = await this.getSnapshot(approvalId, toVersion); if (!toSnapshot) { throw new Error(`Snapshot version ${toVersion} not found`); } toContent = toSnapshot.content; } // Basic diff computation (we'll enhance this when we add the diff library) const fromLines = fromContent.split('\n'); const toLines = toContent.split('\n'); // Simple line-by-line comparison for now const diffLines = []; let additions = 0; let deletions = 0; let changes = 0; // This is a very basic implementation - will be replaced with proper diff library const maxLines = Math.max(fromLines.length, toLines.length); for (let i = 0; i < maxLines; i++) { const fromLine = fromLines[i]; const toLine = toLines[i]; if (fromLine !== undefined && toLine !== undefined) { if (fromLine === toLine) { diffLines.push({ type: 'normal', oldLineNumber: i + 1, newLineNumber: i + 1, content: fromLine }); } else { changes++; diffLines.push({ type: 'delete', oldLineNumber: i + 1, content: fromLine }); diffLines.push({ type: 'add', newLineNumber: i + 1, content: toLine }); } } else if (fromLine !== undefined) { deletions++; diffLines.push({ type: 'delete', oldLineNumber: i + 1, content: fromLine }); } else if (toLine !== undefined) { additions++; diffLines.push({ type: 'add', newLineNumber: i + 1, content: toLine }); } } return { additions, deletions, changes, chunks: [{ oldStart: 1, oldLines: fromLines.length, newStart: 1, newLines: toLines.length, lines: diffLines }] }; } generateSnapshotId() { return `snapshot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } generateId() { return `approval_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } } //# sourceMappingURL=approval-storage.js.map