@pimzino/spec-workflow-mcp
Version:
MCP server for spec-driven development workflow with real-time web dashboard
251 lines • 10.2 kB
JavaScript
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