Skip to main content
Glama

Spec Workflow MCP

approval-storage.ts20.2 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 interface ApprovalComment { type: 'selection' | 'general'; selectedText?: string; comment: string; timestamp: string; lineNumber?: number; characterPosition?: number; highlightColor?: string; // Color for highlighting the selected text } export interface DocumentSnapshot { id: string; approvalId: string; approvalTitle: string; version: number; timestamp: string; trigger: 'initial' | 'revision_requested' | 'approved' | 'manual'; status: 'pending' | 'approved' | 'rejected' | 'needs-revision'; content: string; fileStats: { size: number; lines: number; lastModified: string; }; comments?: ApprovalComment[]; annotations?: string; } export interface SnapshotMetadata { approvalId: string; currentVersion: number; snapshots: { version: number; filename: string; timestamp: string; trigger: string; }[]; } export interface FileSnapshotMetadata { filePath: string; currentVersion: number; snapshots: { version: number; filename: string; timestamp: string; trigger: string; approvalId: string; approvalTitle: string; }[]; } export interface DiffResult { additions: number; deletions: number; changes: number; chunks: DiffChunk[]; } export interface DiffChunk { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: DiffLine[]; } export interface DiffLine { type: 'add' | 'delete' | 'normal'; oldLineNumber?: number; newLineNumber?: number; content: string; } export interface ApprovalRequest { id: string; title: string; filePath: string; // Path to the file to be reviewed type: 'document' | 'action'; status: 'pending' | 'approved' | 'rejected' | 'needs-revision'; createdAt: string; respondedAt?: string; response?: string; annotations?: string; comments?: ApprovalComment[]; revisionHistory?: { version: number; content: string; timestamp: string; reason?: string; }[]; metadata?: Record<string, any>; category: 'spec' | 'steering'; categoryName: string; // spec or steering document name } export class ApprovalStorage extends EventEmitter { public projectPath: string; // Make public so dashboard server can access it private approvalsDir: string; private watcher?: chokidar.FSWatcher; constructor(projectPath: string) { 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(): Promise<void> { // 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(): Promise<void> { 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: string, filePath: string, category: 'spec' | 'steering', categoryName: string, type: 'document' | 'action' = 'document', metadata?: Record<string, any> ): Promise<string> { const id = this.generateId(); const approval: ApprovalRequest = { 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) { // Log error but don't fail the approval creation console.warn(`Failed to capture initial snapshot for approval ${id}:`, error); } return id; } async getApproval(id: string): Promise<ApprovalRequest | null> { // 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) as ApprovalRequest; } catch { return null; } } private async findApprovalPath(id: string): Promise<string | null> { // 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: string, status: 'approved' | 'rejected' | 'needs-revision', response: string, annotations?: string, comments?: ApprovalComment[] ): Promise<void> { 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) { console.warn(`Failed to capture revision snapshot for approval ${id}:`, error); } } else if (status === 'approved') { try { await this.captureSnapshot(id, 'approved'); } catch (error) { console.warn(`Failed to capture approval snapshot for approval ${id}:`, error); } } 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: string, newContent: string, reason?: string ): Promise<string> { 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(): Promise<ApprovalRequest[]> { const allApprovals = await this.getAllApprovals(); return allApprovals.filter(approval => approval.status === 'pending' ); } async getAllApprovals(): Promise<ApprovalRequest[]> { try { const approvals: ApprovalRequest[] = []; 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) as ApprovalRequest; 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: string): Promise<boolean> { 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: number = 7): Promise<void> { 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) as ApprovalRequest; 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: string, trigger: 'initial' | 'revision_requested' | 'approved' | 'manual'): Promise<void> { 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: string; let stats: any; 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: FileSnapshotMetadata; 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) { console.log(`Initial snapshot already exists for ${approval.filePath}, skipping creation`); return; } } // Create new snapshot const version = metadata.currentVersion + 1; const snapshotId = `snapshot-${version.toString().padStart(3, '0')}`; const timestamp = new Date().toISOString(); const snapshot: DocumentSnapshot = { 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: string): Promise<DocumentSnapshot[]> { 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: FileSnapshotMetadata = JSON.parse(metadataContent); const snapshots: DocumentSnapshot[] = []; for (const snapMeta of metadata.snapshots) { const snapPath = join(snapshotsDir, snapMeta.filename); const snapshotContent = await fs.readFile(snapPath, 'utf-8'); const snapshot: DocumentSnapshot = JSON.parse(snapshotContent); snapshots.push(snapshot); } return snapshots.sort((a, b) => a.version - b.version); } catch { return []; } } async getSnapshot(approvalId: string, version: number): Promise<DocumentSnapshot | null> { const snapshots = await this.getSnapshots(approvalId); return snapshots.find(s => s.version === version) || null; } async getCurrentFileContent(approvalId: string): Promise<string | null> { 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: string, fromVersion: number, toVersion: number | 'current'): Promise<DiffResult> { let fromContent: string; let toContent: string; 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: DiffLine[] = []; 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 }] }; } private generateSnapshotId(): string { return `snapshot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } private generateId(): string { return `approval_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Pimzino/spec-workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server