diff --git a/.spec-workflow/session.json b/.spec-workflow/session.json new file mode 100644 index 0000000..df3d45e --- /dev/null +++ b/.spec-workflow/session.json @@ -0,0 +1,5 @@ +{ + "dashboardUrl": "http://localhost:5000", + "startedAt": "2025-11-02T20:16:33.364Z", + "pid": 10856 +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f93ae..12dafc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2025-11-03 + +### Added +- Added NEW Unified Multi-Project Dashboard Implementation! +- 'ESC' key now closes all dialogs and modals in the dashboard. + + +### Announcement +- Deprecated the `--AutoStartDashboard` flag as it is no longer needed. + ## [1.0.1] - 2025-09-24 ### Changed diff --git a/MULTI_PROJECT_IMPLEMENTATION.md b/MULTI_PROJECT_IMPLEMENTATION.md new file mode 100644 index 0000000..f73ea3e --- /dev/null +++ b/MULTI_PROJECT_IMPLEMENTATION.md @@ -0,0 +1,225 @@ +# Multi-Project Dashboard Implementation Summary + +## Overview + +The Spec Workflow MCP Dashboard has been successfully redesigned to support multiple projects in a single dashboard instance. This implementation allows users to manage and switch between multiple active projects seamlessly. + +## What Was Implemented + +### 1. Global Project Registry (`src/core/project-registry.ts`) +- **Location**: `~/.spec-workflow-mcp/activeProjects.json` +- **Features**: + - Atomic file operations to prevent race conditions + - Automatic cleanup of stale projects (dead processes) + - Project identification by absolute path + - Auto-detection of project names from directory names + +### 2. Backend Changes + +#### Dashboard Server (`src/dashboard/server.ts`) +- **New API Endpoints**: + - `GET /api/projects/list` - Returns all active projects from global registry + - `GET /api/projects/current` - Returns current project info (path, name) + - `GET /api/info` - Enhanced to include `projectPath` +- **Registry Integration**: + - Registers project on startup + - Unregisters project on shutdown + - Periodic cleanup of stale projects every 30 seconds + - Cleanup on initial startup + +#### MCP Server (`src/server.ts`) +- **Registry Integration**: + - Registers project when dashboard is discovered + - Unregisters project on shutdown + - Handles dashboard monitoring with registry updates + +### 3. Frontend Changes + +#### Project Sidebar (`src/dashboard_frontend/src/modules/components/ProjectSidebar.tsx`) +- **Features**: + - Displays all active projects from global registry + - Highlights current project + - Click to switch between projects + - Auto-refreshes project list every 2.5 seconds + - Responsive design (collapsible on mobile) + - Shows project names and paths + - Empty state handling + +#### WebSocket Provider (`src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx`) +- **Multi-Project Support**: + - Tracks current project path and dashboard URL + - `switchProject()` function for changing projects + - Disconnects from old WebSocket and connects to new one + - Redirects to new dashboard URL on project switch + - Fetches current project info on mount + +#### API Provider (`src/dashboard_frontend/src/modules/api/api.tsx`) +- **Project Awareness**: + - Exposes `currentProjectPath` in context + - All API calls use relative URLs (work correctly after redirect) + +#### App Integration (`src/dashboard_frontend/src/modules/app/App.tsx`) +- **UI Updates**: + - Project sidebar toggle button in header + - Sidebar state management + - Responsive layout with sidebar + - Mobile-friendly design + +## How to Test + +### 1. Start Multiple Dashboard Instances + +Open multiple terminal windows and start dashboards for different projects: + +```bash +# Terminal 1 - Project A +cd /path/to/project-a +spec-workflow-mcp --dashboard --port 3001 + +# Terminal 2 - Project B +cd /path/to/project-b +spec-workflow-mcp --dashboard --port 3002 + +# Terminal 3 - Project C +cd /path/to/project-c +spec-workflow-mcp --dashboard --port 3003 +``` + +### 2. Verify Global Registry + +Check that all projects are registered: + +```bash +# On macOS/Linux +cat ~/.spec-workflow-mcp/activeProjects.json + +# On Windows +type %USERPROFILE%\.spec-workflow-mcp\activeProjects.json +``` + +You should see entries for all three projects with their paths, names, dashboard URLs, and PIDs. + +### 3. Test Project Switching + +1. Open any dashboard (e.g., http://localhost:3001) +2. Click the hamburger menu icon (☰) in the header to open the project sidebar +3. You should see: + - Current project highlighted at the top + - List of other available projects below +4. Click on another project to switch to it +5. The page should redirect to that project's dashboard +6. Verify that the data shown is for the new project + +### 4. Test Auto-Discovery + +1. With one dashboard open, start a new dashboard for another project +2. Wait 2-3 seconds +3. The project sidebar should automatically update to show the new project +4. Stop one of the dashboards +5. Wait 2-3 seconds +6. The stopped project should disappear from the sidebar + +### 5. Test Cleanup + +1. Start a dashboard +2. Verify it appears in the registry +3. Kill the process forcefully (Ctrl+C or kill command) +4. Start another dashboard +5. The stale entry should be cleaned up automatically + +### 6. Test MCP Server Integration + +```bash +# Start MCP server with auto-start dashboard +spec-workflow-mcp /path/to/project --AutoStartDashboard --port 3000 + +# Verify the project is registered in the global registry +cat ~/.spec-workflow-mcp/activeProjects.json +``` + +## Key Features + +### 1. Seamless Project Switching +- Click to switch between any active project +- Automatic redirect to the correct dashboard URL +- No data mixing between projects + +### 2. Auto-Discovery +- Projects automatically appear when dashboards start +- Projects automatically disappear when dashboards stop +- Real-time updates every 2.5 seconds + +### 3. Process Management +- Automatic cleanup of dead processes +- Atomic file operations prevent corruption +- Safe concurrent access to registry + +### 4. User Experience +- Responsive sidebar design +- Mobile-friendly interface +- Clear visual indicators for current project +- Project paths shown for disambiguation + +### 5. Robustness +- Handles multiple dashboards on different ports +- Graceful handling of network issues +- Proper cleanup on shutdown + +## Architecture Decisions + +1. **Global Registry**: Centralized in `~/.spec-workflow-mcp/` for cross-dashboard visibility +2. **Project Identification**: Auto-detect from directory name using `path.basename()` +3. **WebSocket Architecture**: One connection per project (disconnect old, connect new on switch) +4. **UI Pattern**: Sidebar for project list, single active project view at a time +5. **Auto-Discovery**: Poll registry every 2.5 seconds for dynamic updates +6. **Redirect Strategy**: Full page redirect when switching projects (simplest and most reliable) + +## Files Modified + +### New Files +- `src/core/project-registry.ts` - Global registry manager +- `src/dashboard_frontend/src/modules/components/ProjectSidebar.tsx` - Project switcher UI +- `MULTI_PROJECT_IMPLEMENTATION.md` - This documentation + +### Modified Files +- `src/dashboard/server.ts` - Registry integration, new API endpoints +- `src/server.ts` - Registry integration +- `src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx` - Multi-project WS support +- `src/dashboard_frontend/src/modules/api/api.tsx` - Project-aware API calls +- `src/dashboard_frontend/src/modules/app/App.tsx` - UI layout with sidebar + +## Future Enhancements + +Potential improvements for future versions: + +1. **Project Groups**: Organize projects into groups/workspaces +2. **Recent Projects**: Show recently accessed projects +3. **Project Search**: Filter projects by name or path +4. **Project Favorites**: Pin frequently used projects +5. **Project Metadata**: Store custom project descriptions/tags +6. **Multi-Window Support**: Open multiple projects in separate browser windows +7. **Project Health**: Show status indicators (specs count, tasks progress, etc.) +8. **Keyboard Shortcuts**: Quick project switching with keyboard + +## Troubleshooting + +### Projects Not Appearing in Sidebar +- Check that dashboards are running on different ports +- Verify the global registry file exists: `~/.spec-workflow-mcp/activeProjects.json` +- Check browser console for errors +- Wait 2-3 seconds for auto-refresh + +### Stale Projects in List +- The cleanup runs every 30 seconds +- Manually start any dashboard to trigger cleanup +- Delete the registry file to reset: `rm ~/.spec-workflow-mcp/activeProjects.json` + +### Project Switch Not Working +- Ensure the target dashboard is still running +- Check that the dashboard URL is accessible +- Verify no firewall blocking localhost connections +- Check browser console for errors + +## Conclusion + +The multi-project dashboard support is now fully implemented and ready for use. Users can seamlessly manage multiple projects from a single dashboard interface, with automatic discovery and cleanup of projects. diff --git a/UNIFIED_DASHBOARD_MIGRATION.md b/UNIFIED_DASHBOARD_MIGRATION.md new file mode 100644 index 0000000..acded7f --- /dev/null +++ b/UNIFIED_DASHBOARD_MIGRATION.md @@ -0,0 +1,312 @@ +# Unified Multi-Project Dashboard - Migration Guide + +## Overview + +The Spec Workflow MCP system has been redesigned to use a **single unified dashboard** that manages multiple projects concurrently on one port. This is a significant architectural change from the previous multi-dashboard approach. + +## What Changed + +### Before (Old Architecture) +- Each project had its own dashboard server on a separate port +- Projects linked to each other via redirects +- `--AutoStartDashboard` flag started a per-project dashboard +- Registry tracked dashboard URLs for each project + +### After (New Architecture) +- **One dashboard server** manages all projects on a single port +- Projects appear/disappear dynamically based on MCP server registration +- Dashboard watches the global registry and auto-loads projects +- No page redirects - project switching happens in-memory +- `--AutoStartDashboard` is deprecated + +## New Usage + +### 1. Start the Unified Dashboard (Once) + +```bash +# Start the dashboard server on port 5000 (or any port you prefer) +spec-workflow-mcp --dashboard --port 5000 +``` + +This single dashboard will: +- Monitor `~/.spec-workflow-mcp/activeProjects.json` +- Automatically load projects as MCP servers register +- Handle multiple projects concurrently +- Provide a sidebar to switch between projects + +### 2. Start MCP Servers (Per Project) + +```bash +# Terminal 1 - Project A +cd /path/to/project-a +spec-workflow-mcp + +# Terminal 2 - Project B +cd /path/to/project-b +spec-workflow-mcp + +# Terminal 3 - Project C +cd /path/to/project-c +spec-workflow-mcp +``` + +Each MCP server will: +- Register itself in `~/.spec-workflow-mcp/activeProjects.json` +- Appear automatically in the dashboard sidebar +- Unregister when stopped + +### 3. Use the Dashboard + +1. Open http://localhost:5000 in your browser +2. Click the hamburger menu (☰) to open the project sidebar +3. See all active projects listed +4. Click any project to switch to it +5. All data updates in real-time without page reload + +## Migration Steps + +### For Existing Users + +1. **Stop all existing dashboards and MCP servers** + ```bash + # Stop all running instances + ``` + +2. **Clear old registry** (optional, for clean start) + ```bash + rm ~/.spec-workflow-mcp/activeProjects.json + ``` + +3. **Start the new unified dashboard** + ```bash + spec-workflow-mcp --dashboard --port 5000 + ``` + +4. **Start your MCP servers** (without `--AutoStartDashboard`) + ```bash + cd /path/to/project + spec-workflow-mcp + ``` + +5. **Open the dashboard** + - Navigate to http://localhost:5000 + - Your projects will appear in the sidebar + +### Deprecated Flags + +- `--AutoStartDashboard`: Still works but shows a deprecation warning. The flag is ignored, and you should start the dashboard separately. + +## Architecture Details + +### Backend + +#### Global Registry +- **Location**: `~/.spec-workflow-mcp/activeProjects.json` +- **Format**: + ```json + { + "projectId": { + "projectId": "abc123...", + "projectPath": "/absolute/path/to/project", + "projectName": "project-name", + "pid": 12345, + "registeredAt": "2025-01-01T00:00:00.000Z" + } + } + ``` +- **Project ID**: SHA-1 hash of absolute path (first 16 chars, base64url) + +#### Multi-Project Dashboard Server +- **File**: `src/dashboard/multi-server.ts` +- **Class**: `MultiProjectDashboardServer` +- **Features**: + - Watches registry file for changes + - Manages multiple `ProjectContext` instances + - One `SpecParser`, `SpecWatcher`, `ApprovalStorage` per project + - Project-scoped API endpoints + - WebSocket multiplexing with `projectId` + +#### API Endpoints +All endpoints are now project-scoped: +- `GET /api/projects/list` - List all projects +- `GET /api/projects/:projectId/info` - Project info +- `GET /api/projects/:projectId/specs` - Specs list +- `GET /api/projects/:projectId/approvals` - Approvals list +- `PUT /api/projects/:projectId/specs/:name/:document` - Save spec +- And all other endpoints follow the same pattern + +#### WebSocket +- **Endpoint**: `/ws?projectId=` +- **Messages**: All include `projectId` field +- **Types**: + - `initial` - Initial data for a project + - `projects-update` - Global project list update + - `spec-update` - Project-scoped spec changes + - `approval-update` - Project-scoped approval changes + - `steering-update` - Project-scoped steering changes + - `task-status-update` - Project-scoped task updates + +### Frontend + +#### Provider Hierarchy +``` +App +├── ThemeProvider +└── ProjectProvider (manages project list & selection) + └── AppWithProviders + └── WebSocketProvider (connects with projectId) + └── AppInner + └── ApiProvider (project-scoped APIs) + └── NotificationProvider + └── Routes & Components +``` + +#### Key Components +- **ProjectProvider**: Manages projects list, current selection, polling +- **WebSocketProvider**: Connects to `/ws?projectId=X`, handles project-scoped messages +- **ApiProvider**: Prefixes all calls with `/api/projects/${projectId}` +- **ProjectSidebar**: Displays projects, allows switching + +#### Project Switching +1. User clicks project in sidebar +2. `ProjectProvider` updates `currentProjectId` +3. `WebSocketProvider` reconnects with new `projectId` +4. `ApiProvider` updates API prefix +5. Data reloads for new project +6. No page redirect needed + +## Benefits + +### For Users +- **Single browser tab** for all projects +- **Faster switching** - no page reloads +- **Unified interface** - consistent experience +- **Auto-discovery** - projects appear automatically +- **Resource efficient** - one dashboard process + +### For Developers +- **Cleaner architecture** - centralized management +- **Better scalability** - handles many projects efficiently +- **Simpler deployment** - one dashboard to manage +- **Easier testing** - single endpoint to test + +## Troubleshooting + +### Projects Not Appearing +1. Check registry file exists: `cat ~/.spec-workflow-mcp/activeProjects.json` +2. Verify MCP servers are running: `ps aux | grep spec-workflow-mcp` +3. Check dashboard logs for errors +4. Wait 2-3 seconds for auto-refresh + +### Dashboard Won't Start +1. Check port is not in use: `lsof -i :5000` +2. Try a different port: `--port 5001` +3. Check file permissions on `~/.spec-workflow-mcp/` + +### WebSocket Connection Issues +1. Check browser console for errors +2. Verify dashboard is running +3. Try refreshing the page +4. Check firewall settings + +### Stale Projects in List +- Projects are auto-cleaned every 30 seconds +- Manual cleanup: Delete registry file and restart dashboard + +## API Changes Summary + +### Removed +- `/api/specs` (non-scoped) +- `/api/approvals` (non-scoped) +- `/api/info` (non-scoped) +- `/api/projects/current` (no longer needed) + +### Added +- `/api/projects/list` +- `/api/projects/:projectId/*` (all project-scoped endpoints) + +### Changed +- All endpoints now require `projectId` parameter +- WebSocket requires `?projectId=` query parameter +- All WebSocket messages include `projectId` field + +## Code Examples + +### Starting Dashboard Programmatically +```typescript +import { MultiProjectDashboardServer } from './dashboard/multi-server.js'; + +const dashboard = new MultiProjectDashboardServer({ + port: 5000, + autoOpen: true +}); + +await dashboard.start(); +``` + +### Registering a Project +```typescript +import { ProjectRegistry } from './core/project-registry.js'; + +const registry = new ProjectRegistry(); +const projectId = await registry.registerProject('/path/to/project', process.pid); +console.log('Registered:', projectId); +``` + +### Frontend: Using Project Context +```typescript +import { useProjects } from './modules/projects/ProjectProvider'; + +function MyComponent() { + const { projects, currentProject, setCurrentProject } = useProjects(); + + return ( + + ); +} +``` + +## Performance Considerations + +- **Registry polling**: Every 2.5 seconds (frontend) +- **Cleanup interval**: Every 30 seconds (backend) +- **WebSocket reconnect**: 2 seconds delay on disconnect +- **File watching**: Uses chokidar for efficient file system monitoring + +## Security Notes + +- Registry file is in user's home directory (`~/.spec-workflow-mcp/`) +- Dashboard binds to `0.0.0.0` (accessible on network) +- No authentication - intended for local development +- Process IDs used for cleanup validation + +## Future Enhancements + +Potential improvements for future versions: +- Project groups/workspaces +- Project search/filter +- Recent projects list +- Project favorites +- Custom project metadata +- Multi-window support +- Project health indicators +- Keyboard shortcuts for switching + +## Support + +For issues or questions: +- GitHub: https://github.com/Pimzino/spec-workflow-mcp +- Documentation: Check the docs/ folder +- Logs: Check console output from dashboard and MCP servers + +## Conclusion + +The unified dashboard architecture provides a better user experience and cleaner codebase. While it requires a small change in workflow (starting dashboard separately), the benefits of having all projects in one place far outweigh the migration effort. + +**Happy multi-project development!** 🚀 diff --git a/package.json b/package.json index 7f8168c..99e0b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pimzino/spec-workflow-mcp", - "version": "1.0.1", + "version": "1.1.0", "description": "MCP server for spec-driven development workflow with real-time web dashboard", "main": "dist/index.js", "type": "module", diff --git a/src/core/project-registry.ts b/src/core/project-registry.ts new file mode 100644 index 0000000..1091194 --- /dev/null +++ b/src/core/project-registry.ts @@ -0,0 +1,200 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { promises as fs } from 'fs'; +import { basename, resolve } from 'path'; +import { createHash } from 'crypto'; + +export interface ProjectRegistryEntry { + projectId: string; + projectPath: string; + projectName: string; + pid: number; + registeredAt: string; +} + +/** + * Generate a stable projectId from an absolute path + * Uses SHA-1 hash encoded as base64url + */ +export function generateProjectId(absolutePath: string): string { + const hash = createHash('sha1').update(absolutePath).digest('base64url'); + // Take first 16 characters for readability + return hash.substring(0, 16); +} + +export class ProjectRegistry { + private registryPath: string; + private registryDir: string; + + constructor() { + this.registryDir = join(homedir(), '.spec-workflow-mcp'); + this.registryPath = join(this.registryDir, 'activeProjects.json'); + } + + /** + * Ensure the registry directory exists + */ + private async ensureRegistryDir(): Promise { + try { + await fs.mkdir(this.registryDir, { recursive: true }); + } catch (error) { + // Directory might already exist, ignore + } + } + + /** + * Read the registry file with atomic operations + * Returns a map keyed by projectId + */ + private async readRegistry(): Promise> { + await this.ensureRegistryDir(); + + try { + const content = await fs.readFile(this.registryPath, 'utf-8'); + const data = JSON.parse(content) as Record; + return new Map(Object.entries(data)); + } catch (error: any) { + if (error.code === 'ENOENT') { + // File doesn't exist yet, return empty map + return new Map(); + } + throw error; + } + } + + /** + * Write the registry file atomically + */ + private async writeRegistry(registry: Map): Promise { + await this.ensureRegistryDir(); + + const data = Object.fromEntries(registry); + const content = JSON.stringify(data, null, 2); + + // Write to temporary file first, then rename for atomic operation + const tempPath = `${this.registryPath}.tmp`; + await fs.writeFile(tempPath, content, 'utf-8'); + await fs.rename(tempPath, this.registryPath); + } + + /** + * Check if a process is still running + */ + private isProcessAlive(pid: number): boolean { + try { + // Sending signal 0 checks if process exists without actually sending a signal + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } + } + + /** + * Register a project in the global registry + */ + async registerProject(projectPath: string, pid: number): Promise { + const registry = await this.readRegistry(); + + const absolutePath = resolve(projectPath); + const projectId = generateProjectId(absolutePath); + const projectName = basename(absolutePath); + + const entry: ProjectRegistryEntry = { + projectId, + projectPath: absolutePath, + projectName, + pid, + registeredAt: new Date().toISOString() + }; + + registry.set(projectId, entry); + await this.writeRegistry(registry); + return projectId; + } + + /** + * Unregister a project from the global registry by path + */ + async unregisterProject(projectPath: string): Promise { + const registry = await this.readRegistry(); + const absolutePath = resolve(projectPath); + const projectId = generateProjectId(absolutePath); + + registry.delete(projectId); + await this.writeRegistry(registry); + } + + /** + * Unregister a project by projectId + */ + async unregisterProjectById(projectId: string): Promise { + const registry = await this.readRegistry(); + registry.delete(projectId); + await this.writeRegistry(registry); + } + + /** + * Get all active projects from the registry + */ + async getAllProjects(): Promise { + const registry = await this.readRegistry(); + return Array.from(registry.values()); + } + + /** + * Get a specific project by path + */ + async getProject(projectPath: string): Promise { + const registry = await this.readRegistry(); + const absolutePath = resolve(projectPath); + const projectId = generateProjectId(absolutePath); + return registry.get(projectId) || null; + } + + /** + * Get a specific project by projectId + */ + async getProjectById(projectId: string): Promise { + const registry = await this.readRegistry(); + return registry.get(projectId) || null; + } + + /** + * Clean up stale projects (where the process is no longer running) + */ + async cleanupStaleProjects(): Promise { + const registry = await this.readRegistry(); + let removedCount = 0; + + for (const [projectId, entry] of registry.entries()) { + if (!this.isProcessAlive(entry.pid)) { + registry.delete(projectId); + removedCount++; + } + } + + if (removedCount > 0) { + await this.writeRegistry(registry); + } + + return removedCount; + } + + /** + * Check if a project is registered by path + */ + async isProjectRegistered(projectPath: string): Promise { + const registry = await this.readRegistry(); + const absolutePath = resolve(projectPath); + const projectId = generateProjectId(absolutePath); + return registry.has(projectId); + } + + /** + * Get the registry file path for watching + */ + getRegistryPath(): string { + return this.registryPath; + } +} diff --git a/src/dashboard/multi-server.ts b/src/dashboard/multi-server.ts new file mode 100644 index 0000000..9b3021b --- /dev/null +++ b/src/dashboard/multi-server.ts @@ -0,0 +1,738 @@ +import fastify, { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyWebsocket from '@fastify/websocket'; +import { join, dirname, basename, resolve } from 'path'; +import { readFile } from 'fs/promises'; +import { promises as fs } from 'fs'; +import { fileURLToPath } from 'url'; +import open from 'open'; +import { WebSocket } from 'ws'; +import { findAvailablePort, validateAndCheckPort } from './utils.js'; +import { parseTasksFromMarkdown } from '../core/task-parser.js'; +import { ProjectManager } from './project-manager.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +interface WebSocketConnection { + socket: WebSocket; + projectId?: string; +} + +export interface MultiDashboardOptions { + autoOpen?: boolean; + port?: number; +} + +export class MultiProjectDashboardServer { + private app: FastifyInstance; + private projectManager: ProjectManager; + private options: MultiDashboardOptions; + private actualPort: number = 0; + private clients: Set = new Set(); + private packageVersion: string = 'unknown'; + + constructor(options: MultiDashboardOptions = {}) { + this.options = options; + this.projectManager = new ProjectManager(); + this.app = fastify({ logger: false }); + } + + async start() { + // Fetch package version once at startup + try { + const response = await fetch('https://registry.npmjs.org/@pimzino/spec-workflow-mcp/latest'); + if (response.ok) { + const packageInfo = await response.json() as { version?: string }; + this.packageVersion = packageInfo.version || 'unknown'; + } + } catch { + // Fallback to local package.json version if npm request fails + try { + const packageJsonPath = join(__dirname, '..', '..', 'package.json'); + const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent) as { version?: string }; + this.packageVersion = packageJson.version || 'unknown'; + } catch { + // Keep default 'unknown' if both npm and local package.json fail + } + } + + // Initialize project manager + await this.projectManager.initialize(); + + // Register plugins + await this.app.register(fastifyStatic, { + root: join(__dirname, 'public'), + prefix: '/', + }); + + await this.app.register(fastifyWebsocket); + + // WebSocket endpoint for real-time updates + const self = this; + this.app.register(async function (fastify) { + fastify.get('/ws', { websocket: true }, (connection: WebSocketConnection, req) => { + const socket = connection.socket; + + // Get projectId from query parameter + const url = new URL(req.url || '', `http://${req.headers.host}`); + const projectId = url.searchParams.get('projectId') || undefined; + + connection.projectId = projectId; + self.clients.add(connection); + + // Send initial state for the requested project + if (projectId) { + const project = self.projectManager.getProject(projectId); + if (project) { + Promise.all([ + project.parser.getAllSpecs(), + project.approvalStorage.getAllPendingApprovals() + ]) + .then(([specs, approvals]) => { + socket.send( + JSON.stringify({ + type: 'initial', + projectId, + data: { specs, approvals }, + }) + ); + }) + .catch((error) => { + console.error('Error getting initial data:', error); + }); + } + } + + // Send projects list + socket.send( + JSON.stringify({ + type: 'projects-update', + data: { projects: self.projectManager.getProjectsList() } + }) + ); + + // Handle client disconnect + const cleanup = () => { + self.clients.delete(connection); + socket.removeAllListeners(); + }; + + socket.on('close', cleanup); + socket.on('error', cleanup); + socket.on('disconnect', cleanup); + socket.on('end', cleanup); + + // Handle subscription messages + socket.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'subscribe' && msg.projectId) { + connection.projectId = msg.projectId; + + // Send initial data for new subscription + const project = self.projectManager.getProject(msg.projectId); + if (project) { + Promise.all([ + project.parser.getAllSpecs(), + project.approvalStorage.getAllPendingApprovals() + ]) + .then(([specs, approvals]) => { + socket.send( + JSON.stringify({ + type: 'initial', + projectId: msg.projectId, + data: { specs, approvals }, + }) + ); + }) + .catch((error) => { + console.error('Error getting initial data:', error); + }); + } + } + } catch (error) { + // Ignore invalid messages + } + }); + }); + }); + + // Serve Claude icon as favicon + this.app.get('/favicon.ico', async (request, reply) => { + return reply.sendFile('claude-icon.svg'); + }); + + // Setup project manager event handlers + this.setupProjectManagerEvents(); + + // Register API routes + this.registerApiRoutes(); + + // Allocate port + if (this.options.port) { + await validateAndCheckPort(this.options.port); + this.actualPort = this.options.port; + console.error(`Using custom port: ${this.actualPort}`); + } else { + this.actualPort = await findAvailablePort(); + console.error(`Using ephemeral port: ${this.actualPort}`); + } + + // Start server + await this.app.listen({ port: this.actualPort, host: '0.0.0.0' }); + + // Open browser if requested + if (this.options.autoOpen) { + await open(`http://localhost:${this.actualPort}`); + } + + return `http://localhost:${this.actualPort}`; + } + + private setupProjectManagerEvents() { + // Broadcast projects update when projects change + this.projectManager.on('projects-update', (projects) => { + this.broadcastToAll({ + type: 'projects-update', + data: { projects } + }); + }); + + // Broadcast spec changes + this.projectManager.on('spec-change', async (event) => { + const { projectId, ...data } = event; + const project = this.projectManager.getProject(projectId); + if (project) { + const specs = await project.parser.getAllSpecs(); + const archivedSpecs = await project.parser.getAllArchivedSpecs(); + this.broadcastToProject(projectId, { + type: 'spec-update', + projectId, + data: { specs, archivedSpecs } + }); + } + }); + + // Broadcast task updates + this.projectManager.on('task-update', (event) => { + const { projectId, specName } = event; + this.broadcastTaskUpdate(projectId, specName); + }); + + // Broadcast steering changes + this.projectManager.on('steering-change', async (event) => { + const { projectId, steeringStatus } = event; + this.broadcastToProject(projectId, { + type: 'steering-update', + projectId, + data: steeringStatus + }); + }); + + // Broadcast approval changes + this.projectManager.on('approval-change', async (event) => { + const { projectId } = event; + const project = this.projectManager.getProject(projectId); + if (project) { + const approvals = await project.approvalStorage.getAllPendingApprovals(); + this.broadcastToProject(projectId, { + type: 'approval-update', + projectId, + data: approvals + }); + } + }); + } + + private registerApiRoutes() { + // Projects list + this.app.get('/api/projects/list', async () => { + return this.projectManager.getProjectsList(); + }); + + // Add project manually + this.app.post('/api/projects/add', async (request, reply) => { + const { projectPath } = request.body as { projectPath: string }; + if (!projectPath) { + return reply.code(400).send({ error: 'projectPath is required' }); + } + try { + const projectId = await this.projectManager.addProjectByPath(projectPath); + return { projectId, success: true }; + } catch (error: any) { + return reply.code(500).send({ error: error.message }); + } + }); + + // Remove project + this.app.delete('/api/projects/:projectId', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + try { + await this.projectManager.removeProjectById(projectId); + return { success: true }; + } catch (error: any) { + return reply.code(500).send({ error: error.message }); + } + }); + + // Project info + this.app.get('/api/projects/:projectId/info', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const steeringStatus = await project.parser.getProjectSteeringStatus(); + return { + projectId, + projectName: project.projectName, + projectPath: project.projectPath, + steering: steeringStatus, + version: this.packageVersion + }; + }); + + // Specs list + this.app.get('/api/projects/:projectId/specs', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + return await project.parser.getAllSpecs(); + }); + + // Archived specs list + this.app.get('/api/projects/:projectId/specs/archived', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + return await project.parser.getAllArchivedSpecs(); + }); + + // Get spec details + this.app.get('/api/projects/:projectId/specs/:name', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + const spec = await project.parser.getSpec(name); + if (!spec) { + return reply.code(404).send({ error: 'Spec not found' }); + } + return spec; + }); + + // Get all spec documents + this.app.get('/api/projects/:projectId/specs/:name/all', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const specDir = join(project.projectPath, '.spec-workflow', 'specs', name); + const documents = ['requirements', 'design', 'tasks']; + const result: Record = {}; + + for (const doc of documents) { + const docPath = join(specDir, `${doc}.md`); + try { + const content = await readFile(docPath, 'utf-8'); + const stats = await fs.stat(docPath); + result[doc] = { + content, + lastModified: stats.mtime.toISOString() + }; + } catch { + result[doc] = null; + } + } + + return result; + }); + + // Save spec document + this.app.put('/api/projects/:projectId/specs/:name/:document', async (request, reply) => { + const { projectId, name, document } = request.params as { projectId: string; name: string; document: string }; + const { content } = request.body as { content: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const allowedDocs = ['requirements', 'design', 'tasks']; + if (!allowedDocs.includes(document)) { + return reply.code(400).send({ error: 'Invalid document type' }); + } + + if (typeof content !== 'string') { + return reply.code(400).send({ error: 'Content must be a string' }); + } + + const docPath = join(project.projectPath, '.spec-workflow', 'specs', name, `${document}.md`); + + try { + const specDir = join(project.projectPath, '.spec-workflow', 'specs', name); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile(docPath, content, 'utf-8'); + return { success: true, message: 'Document saved successfully' }; + } catch (error: any) { + return reply.code(500).send({ error: `Failed to save document: ${error.message}` }); + } + }); + + // Archive spec + this.app.post('/api/projects/:projectId/specs/:name/archive', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + try { + await project.archiveService.archiveSpec(name); + return { success: true, message: `Spec '${name}' archived successfully` }; + } catch (error: any) { + return reply.code(400).send({ error: error.message }); + } + }); + + // Unarchive spec + this.app.post('/api/projects/:projectId/specs/:name/unarchive', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + try { + await project.archiveService.unarchiveSpec(name); + return { success: true, message: `Spec '${name}' unarchived successfully` }; + } catch (error: any) { + return reply.code(400).send({ error: error.message }); + } + }); + + // Get approvals + this.app.get('/api/projects/:projectId/approvals', async (request, reply) => { + const { projectId } = request.params as { projectId: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + return await project.approvalStorage.getAllPendingApprovals(); + }); + + // Get approval content + this.app.get('/api/projects/:projectId/approvals/:id/content', async (request, reply) => { + const { projectId, id } = request.params as { projectId: string; id: string }; + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + try { + const approval = await project.approvalStorage.getApproval(id); + if (!approval || !approval.filePath) { + return reply.code(404).send({ error: 'Approval not found or no file path' }); + } + + const candidates: string[] = []; + const p = approval.filePath; + candidates.push(join(project.projectPath, p)); + if (p.startsWith('/') || p.match(/^[A-Za-z]:[\\\/]/)) { + candidates.push(p); + } + if (!p.includes('.spec-workflow')) { + candidates.push(join(project.projectPath, '.spec-workflow', p)); + } + + let content: string | null = null; + let resolvedPath: string | null = null; + for (const candidate of candidates) { + try { + const data = await fs.readFile(candidate, 'utf-8'); + content = data; + resolvedPath = candidate; + break; + } catch { + // try next candidate + } + } + + if (content == null) { + return reply.code(500).send({ error: `Failed to read file at any known location for ${approval.filePath}` }); + } + + return { content, filePath: resolvedPath || approval.filePath }; + } catch (error: any) { + return reply.code(500).send({ error: `Failed to read file: ${error.message}` }); + } + }); + + // Approval actions (approve, reject, needs-revision) + this.app.post('/api/projects/:projectId/approvals/:id/:action', async (request, reply) => { + const { projectId, id, action } = request.params as { projectId: string; id: string; action: string }; + const { response, annotations, comments } = request.body as { + response: string; + annotations?: string; + comments?: any[]; + }; + + const project = this.projectManager.getProject(projectId); + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const validActions = ['approve', 'reject', 'needs-revision']; + if (!validActions.includes(action)) { + return reply.code(400).send({ error: 'Invalid action' }); + } + + try { + await project.approvalStorage.updateApproval(id, action as any, response, annotations, comments); + return { success: true }; + } catch (error: any) { + return reply.code(404).send({ error: error.message }); + } + }); + + // Get steering document + this.app.get('/api/projects/:projectId/steering/:name', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const allowedDocs = ['product', 'tech', 'structure']; + if (!allowedDocs.includes(name)) { + return reply.code(400).send({ error: 'Invalid steering document name' }); + } + + const docPath = join(project.projectPath, '.spec-workflow', 'steering', `${name}.md`); + + try { + const content = await readFile(docPath, 'utf-8'); + const stats = await fs.stat(docPath); + return { + content, + lastModified: stats.mtime.toISOString() + }; + } catch { + return { + content: '', + lastModified: new Date().toISOString() + }; + } + }); + + // Save steering document + this.app.put('/api/projects/:projectId/steering/:name', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const { content } = request.body as { content: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + const allowedDocs = ['product', 'tech', 'structure']; + if (!allowedDocs.includes(name)) { + return reply.code(400).send({ error: 'Invalid steering document name' }); + } + + if (typeof content !== 'string') { + return reply.code(400).send({ error: 'Content must be a string' }); + } + + const steeringDir = join(project.projectPath, '.spec-workflow', 'steering'); + const docPath = join(steeringDir, `${name}.md`); + + try { + await fs.mkdir(steeringDir, { recursive: true }); + await fs.writeFile(docPath, content, 'utf-8'); + return { success: true, message: 'Steering document saved successfully' }; + } catch (error: any) { + return reply.code(500).send({ error: `Failed to save steering document: ${error.message}` }); + } + }); + + // Get task progress + this.app.get('/api/projects/:projectId/specs/:name/tasks/progress', async (request, reply) => { + const { projectId, name } = request.params as { projectId: string; name: string }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + try { + const spec = await project.parser.getSpec(name); + if (!spec || !spec.phases.tasks.exists) { + return reply.code(404).send({ error: 'Spec or tasks not found' }); + } + + const tasksPath = join(project.projectPath, '.spec-workflow', 'specs', name, 'tasks.md'); + const tasksContent = await readFile(tasksPath, 'utf-8'); + const parseResult = parseTasksFromMarkdown(tasksContent); + + const totalTasks = parseResult.summary.total; + const completedTasks = parseResult.summary.completed; + const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; + + return { + total: totalTasks, + completed: completedTasks, + inProgress: parseResult.inProgressTask, + progress: progress, + taskList: parseResult.tasks, + lastModified: spec.phases.tasks.lastModified || spec.lastModified + }; + } catch (error: any) { + return reply.code(500).send({ error: `Failed to get task progress: ${error.message}` }); + } + }); + + // Update task status + this.app.put('/api/projects/:projectId/specs/:name/tasks/:taskId/status', async (request, reply) => { + const { projectId, name, taskId } = request.params as { projectId: string; name: string; taskId: string }; + const { status } = request.body as { status: 'pending' | 'in-progress' | 'completed' }; + const project = this.projectManager.getProject(projectId); + + if (!project) { + return reply.code(404).send({ error: 'Project not found' }); + } + + if (!status || !['pending', 'in-progress', 'completed'].includes(status)) { + return reply.code(400).send({ error: 'Invalid status. Must be pending, in-progress, or completed' }); + } + + try { + const tasksPath = join(project.projectPath, '.spec-workflow', 'specs', name, 'tasks.md'); + + let tasksContent: string; + try { + tasksContent = await readFile(tasksPath, 'utf-8'); + } catch (error: any) { + if (error.code === 'ENOENT') { + return reply.code(404).send({ error: 'Tasks file not found' }); + } + throw error; + } + + const parseResult = parseTasksFromMarkdown(tasksContent); + const task = parseResult.tasks.find(t => t.id === taskId); + + if (!task) { + return reply.code(404).send({ error: `Task ${taskId} not found` }); + } + + if (task.status === status) { + return { + success: true, + message: `Task ${taskId} already has status ${status}`, + task: { ...task, status } + }; + } + + const { updateTaskStatus } = await import('../core/task-parser.js'); + const updatedContent = updateTaskStatus(tasksContent, taskId, status); + + if (updatedContent === tasksContent) { + return reply.code(500).send({ error: `Failed to update task ${taskId} in markdown content` }); + } + + await fs.writeFile(tasksPath, updatedContent, 'utf-8'); + + this.broadcastTaskUpdate(projectId, name); + + return { + success: true, + message: `Task ${taskId} status updated to ${status}`, + task: { ...task, status } + }; + } catch (error: any) { + return reply.code(500).send({ error: `Failed to update task status: ${error.message}` }); + } + }); + } + + private broadcastToAll(message: any) { + const messageStr = JSON.stringify(message); + this.clients.forEach((connection) => { + if (connection.socket.readyState === 1) { + connection.socket.send(messageStr); + } + }); + } + + private broadcastToProject(projectId: string, message: any) { + const messageStr = JSON.stringify(message); + this.clients.forEach((connection) => { + if (connection.socket.readyState === 1 && connection.projectId === projectId) { + connection.socket.send(messageStr); + } + }); + } + + private async broadcastTaskUpdate(projectId: string, specName: string) { + try { + const project = this.projectManager.getProject(projectId); + if (!project) return; + + const tasksPath = join(project.projectPath, '.spec-workflow', 'specs', specName, 'tasks.md'); + const tasksContent = await readFile(tasksPath, 'utf-8'); + const parseResult = parseTasksFromMarkdown(tasksContent); + + this.broadcastToProject(projectId, { + type: 'task-status-update', + projectId, + data: { + specName, + taskList: parseResult.tasks, + summary: parseResult.summary, + inProgress: parseResult.inProgressTask + } + }); + } catch (error) { + console.error('Error broadcasting task update:', error); + } + } + + async stop() { + // Close all WebSocket connections + this.clients.forEach((connection) => { + try { + connection.socket.removeAllListeners(); + if (connection.socket.readyState === 1) { + connection.socket.close(); + } + } catch (error) { + // Ignore cleanup errors + } + }); + this.clients.clear(); + + // Stop project manager + await this.projectManager.stop(); + + // Close the Fastify server + await this.app.close(); + } + + getUrl(): string { + return `http://localhost:${this.actualPort}`; + } +} diff --git a/src/dashboard/project-manager.ts b/src/dashboard/project-manager.ts new file mode 100644 index 0000000..0df6bd9 --- /dev/null +++ b/src/dashboard/project-manager.ts @@ -0,0 +1,287 @@ +import { EventEmitter } from 'events'; +import chokidar from 'chokidar'; +import { SpecParser } from './parser.js'; +import { SpecWatcher } from './watcher.js'; +import { ApprovalStorage } from './approval-storage.js'; +import { SpecArchiveService } from '../core/archive-service.js'; +import { ProjectRegistry, ProjectRegistryEntry, generateProjectId } from '../core/project-registry.js'; + +export interface ProjectContext { + projectId: string; + projectPath: string; + projectName: string; + parser: SpecParser; + watcher: SpecWatcher; + approvalStorage: ApprovalStorage; + archiveService: SpecArchiveService; +} + +export class ProjectManager extends EventEmitter { + private registry: ProjectRegistry; + private projects: Map = new Map(); + private registryWatcher?: chokidar.FSWatcher; + private cleanupInterval?: NodeJS.Timeout; + + constructor() { + super(); + this.registry = new ProjectRegistry(); + } + + /** + * Initialize the project manager + * Loads projects from registry and starts watching for changes + */ + async initialize(): Promise { + // Clean up stale projects first + await this.registry.cleanupStaleProjects(); + + // Load all projects from registry + await this.loadProjectsFromRegistry(); + + // Watch registry file for changes + this.startRegistryWatcher(); + + // Periodic cleanup every 30 seconds + this.cleanupInterval = setInterval(async () => { + await this.cleanupStaleProjects(); + }, 30000); + } + + /** + * Load all projects from the registry + */ + private async loadProjectsFromRegistry(): Promise { + const entries = await this.registry.getAllProjects(); + + for (const entry of entries) { + if (!this.projects.has(entry.projectId)) { + await this.addProject(entry); + } + } + } + + /** + * Start watching the registry file for changes + */ + private startRegistryWatcher(): void { + const registryPath = this.registry.getRegistryPath(); + + this.registryWatcher = chokidar.watch(registryPath, { + ignoreInitial: true, + persistent: true, + ignorePermissionErrors: true + }); + + this.registryWatcher.on('change', async () => { + await this.syncWithRegistry(); + }); + + this.registryWatcher.on('add', async () => { + await this.syncWithRegistry(); + }); + } + + /** + * Sync current projects with registry + * Add new projects, remove deleted ones + */ + private async syncWithRegistry(): Promise { + try { + const entries = await this.registry.getAllProjects(); + const registryIds = new Set(entries.map(e => e.projectId)); + const currentIds = new Set(this.projects.keys()); + + // Add new projects + for (const entry of entries) { + if (!currentIds.has(entry.projectId)) { + await this.addProject(entry); + } + } + + // Remove deleted projects + for (const projectId of currentIds) { + if (!registryIds.has(projectId)) { + await this.removeProject(projectId); + } + } + + // Emit projects update event + this.emit('projects-update', this.getProjectsList()); + } catch (error) { + console.error('Error syncing with registry:', error); + } + } + + /** + * Add a project context + */ + private async addProject(entry: ProjectRegistryEntry): Promise { + try { + const parser = new SpecParser(entry.projectPath); + const watcher = new SpecWatcher(entry.projectPath, parser); + const approvalStorage = new ApprovalStorage(entry.projectPath); + const archiveService = new SpecArchiveService(entry.projectPath); + + // Start watchers + await watcher.start(); + await approvalStorage.start(); + + // Forward events with projectId + watcher.on('change', (event) => { + this.emit('spec-change', { projectId: entry.projectId, ...event }); + }); + + watcher.on('task-update', (event) => { + this.emit('task-update', { projectId: entry.projectId, ...event }); + }); + + watcher.on('steering-change', (event) => { + this.emit('steering-change', { projectId: entry.projectId, ...event }); + }); + + approvalStorage.on('approval-change', () => { + this.emit('approval-change', { projectId: entry.projectId }); + }); + + const context: ProjectContext = { + projectId: entry.projectId, + projectPath: entry.projectPath, + projectName: entry.projectName, + parser, + watcher, + approvalStorage, + archiveService + }; + + this.projects.set(entry.projectId, context); + console.error(`Project added: ${entry.projectName} (${entry.projectId})`); + + // Emit project added event + this.emit('project-added', entry.projectId); + } catch (error) { + console.error(`Failed to add project ${entry.projectName}:`, error); + } + } + + /** + * Remove a project context + */ + private async removeProject(projectId: string): Promise { + const context = this.projects.get(projectId); + if (!context) return; + + try { + // Stop watchers + await context.watcher.stop(); + await context.approvalStorage.stop(); + + // Remove all listeners + context.watcher.removeAllListeners(); + context.approvalStorage.removeAllListeners(); + + this.projects.delete(projectId); + console.error(`Project removed: ${context.projectName} (${projectId})`); + + // Emit project removed event + this.emit('project-removed', projectId); + } catch (error) { + console.error(`Failed to remove project ${projectId}:`, error); + } + } + + /** + * Clean up stale projects (dead processes) + */ + private async cleanupStaleProjects(): Promise { + const removedCount = await this.registry.cleanupStaleProjects(); + if (removedCount > 0) { + // Registry changed, sync will be triggered by watcher + console.error(`Cleaned up ${removedCount} stale project(s)`); + } + } + + /** + * Get a project context by ID + */ + getProject(projectId: string): ProjectContext | undefined { + return this.projects.get(projectId); + } + + /** + * Get all project contexts + */ + getAllProjects(): ProjectContext[] { + return Array.from(this.projects.values()); + } + + /** + * Get projects list for API + */ + getProjectsList(): Array<{ projectId: string; projectName: string; projectPath: string }> { + return Array.from(this.projects.values()).map(p => ({ + projectId: p.projectId, + projectName: p.projectName, + projectPath: p.projectPath + })); + } + + /** + * Manually add a project by path + */ + async addProjectByPath(projectPath: string): Promise { + const entry = await this.registry.getProject(projectPath); + if (entry) { + // Already registered + if (!this.projects.has(entry.projectId)) { + await this.addProject(entry); + } + return entry.projectId; + } + + // Register new project (with dummy PID since it's manual) + const projectId = await this.registry.registerProject(projectPath, process.pid); + + // Get the entry and add it + const newEntry = await this.registry.getProjectById(projectId); + if (newEntry) { + await this.addProject(newEntry); + } + + return projectId; + } + + /** + * Manually remove a project + */ + async removeProjectById(projectId: string): Promise { + await this.removeProject(projectId); + await this.registry.unregisterProjectById(projectId); + } + + /** + * Stop the project manager + */ + async stop(): Promise { + // Stop cleanup interval + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = undefined; + } + + // Stop registry watcher + if (this.registryWatcher) { + this.registryWatcher.removeAllListeners(); + await this.registryWatcher.close(); + this.registryWatcher = undefined; + } + + // Stop all projects + const projectIds = Array.from(this.projects.keys()); + for (const projectId of projectIds) { + await this.removeProject(projectId); + } + + // Remove all listeners + this.removeAllListeners(); + } +} diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index da9e422..69305e9 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -13,6 +13,7 @@ import { findAvailablePort, validateAndCheckPort, checkExistingDashboard, DASHBO import { ApprovalStorage } from './approval-storage.js'; import { parseTasksFromMarkdown } from '../core/task-parser.js'; import { SpecArchiveService } from '../core/archive-service.js'; +import { ProjectRegistry } from '../core/project-registry.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -33,11 +34,13 @@ export class DashboardServer { private parser: SpecParser; private approvalStorage: ApprovalStorage; private archiveService: SpecArchiveService; + private projectRegistry: ProjectRegistry; private options: DashboardOptions; private actualPort: number = 0; private clients: Set = new Set(); private packageVersion: string = 'unknown'; private isUsingExistingDashboard: boolean = false; + private cleanupInterval?: NodeJS.Timeout; constructor(options: DashboardOptions) { this.options = options; @@ -45,6 +48,7 @@ export class DashboardServer { this.watcher = new SpecWatcher(options.projectPath, this.parser); this.approvalStorage = new ApprovalStorage(options.projectPath); this.archiveService = new SpecArchiveService(options.projectPath); + this.projectRegistry = new ProjectRegistry(); this.app = fastify({ logger: false }); } @@ -86,6 +90,9 @@ export class DashboardServer { } } + // Clean up stale projects from registry + await this.projectRegistry.cleanupStaleProjects(); + // Register plugins await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), @@ -147,6 +154,21 @@ export class DashboardServer { return { message: DASHBOARD_TEST_MESSAGE }; }); + // New project management endpoints + this.app.get('/api/projects/list', async () => { + const projects = await this.projectRegistry.getAllProjects(); + return projects; + }); + + this.app.get('/api/projects/current', async () => { + const resolvedPath = resolve(this.options.projectPath); + const projectName = basename(resolvedPath); + return { + projectPath: resolvedPath, + projectName + }; + }); + this.app.get('/api/specs', async () => { const specs = await this.parser.getAllSpecs(); return specs; @@ -323,6 +345,7 @@ export class DashboardServer { return { projectName, + projectPath: resolvedPath, steering: steeringStatus, dashboardUrl: `http://localhost:${this.actualPort}`, version: this.packageVersion @@ -752,12 +775,21 @@ export class DashboardServer { // Start server await this.app.listen({ port: this.actualPort, host: '0.0.0.0' }); + // Register this project in the global registry + const dashboardUrl = `http://localhost:${this.actualPort}`; + await this.projectRegistry.registerProject(this.options.projectPath, process.pid); + + // Start periodic cleanup of stale projects (every 30 seconds) + this.cleanupInterval = setInterval(async () => { + await this.projectRegistry.cleanupStaleProjects(); + }, 30000); + // Open browser if requested if (this.options.autoOpen) { - await open(`http://localhost:${this.actualPort}`); + await open(dashboardUrl); } - return `http://localhost:${this.actualPort}`; + return dashboardUrl; } private async broadcastApprovalUpdate() { @@ -858,6 +890,19 @@ export class DashboardServer { return; } + // Stop periodic cleanup + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = undefined; + } + + // Unregister this project from the global registry + try { + await this.projectRegistry.unregisterProject(this.options.projectPath); + } catch (error) { + console.error('Error unregistering project from registry:', error); + } + // Close all WebSocket connections with proper cleanup this.clients.forEach((client) => { try { diff --git a/src/dashboard_frontend/src/modules/api/api.tsx b/src/dashboard_frontend/src/modules/api/api.tsx index fd7fe71..536dfa5 100644 --- a/src/dashboard_frontend/src/modules/api/api.tsx +++ b/src/dashboard_frontend/src/modules/api/api.tsx @@ -88,6 +88,7 @@ type ApiContextType = { approvals: Approval[]; info?: ProjectInfo; steeringDocuments?: any; + projectId: string | null; reloadAll: () => Promise; getAllSpecDocuments: (name: string) => Promise>; getAllArchivedSpecDocuments: (name: string) => Promise>; @@ -109,7 +110,13 @@ type ApiContextType = { const ApiContext = createContext(undefined); -export function ApiProvider({ initial, children }: { initial?: { specs?: SpecSummary[]; archivedSpecs?: SpecSummary[]; approvals?: Approval[] }; children: React.ReactNode }) { +interface ApiProviderProps { + initial?: { specs?: SpecSummary[]; archivedSpecs?: SpecSummary[]; approvals?: Approval[] }; + projectId: string | null; + children: React.ReactNode; +} + +export function ApiProvider({ initial, projectId, children }: ApiProviderProps) { const { subscribe, unsubscribe } = useWs(); const [specs, setSpecs] = useState(initial?.specs || []); const [archivedSpecs, setArchivedSpecs] = useState(initial?.archivedSpecs || []); @@ -118,23 +125,34 @@ export function ApiProvider({ initial, children }: { initial?: { specs?: SpecSum const [steeringDocuments, setSteeringDocuments] = useState(undefined); const reloadAll = useCallback(async () => { + if (!projectId) return; + const [s, as, a, i] = await Promise.all([ - getJson('/api/specs'), - getJson('/api/specs/archived'), - getJson('/api/approvals'), - getJson('/api/info').catch(() => ({ projectName: 'Project' } as ProjectInfo)), + getJson(`/api/projects/${encodeURIComponent(projectId)}/specs`), + getJson(`/api/projects/${encodeURIComponent(projectId)}/specs/archived`), + getJson(`/api/projects/${encodeURIComponent(projectId)}/approvals`), + getJson(`/api/projects/${encodeURIComponent(projectId)}/info`).catch(() => ({ projectName: 'Project' } as ProjectInfo)), ]); setSpecs(s); setArchivedSpecs(as); setApprovals(a); setInfo(i); setSteeringDocuments(i.steering); - }, []); + }, [projectId]); - // Load initial data including info on mount + // Load initial data when projectId changes useEffect(() => { - reloadAll(); - }, [reloadAll]); + if (projectId) { + reloadAll(); + } else { + // Clear data when no project selected + setSpecs([]); + setArchivedSpecs([]); + setApprovals([]); + setInfo(undefined); + setSteeringDocuments(undefined); + } + }, [projectId, reloadAll]); // Update state when initial websocket data arrives useEffect(() => { @@ -159,48 +177,81 @@ export function ApiProvider({ initial, children }: { initial?: { specs?: SpecSum }; // Subscribe to websocket events that contain actual data - // Only handle events that provide the updated data directly subscribe('spec-update', handleSpecUpdate); subscribe('approval-update', handleApprovalUpdate); subscribe('steering-update', handleSteeringUpdate); - - // Do NOT handle 'update' and 'task-update' events as they are just file change notifications - // without updated data - let individual components handle their own updates via specific events return () => { unsubscribe('spec-update', handleSpecUpdate); unsubscribe('approval-update', handleApprovalUpdate); unsubscribe('steering-update', handleSteeringUpdate); }; - }, [subscribe, unsubscribe, reloadAll]); - - const value = useMemo(() => ({ - specs, - archivedSpecs, - approvals, - info, - steeringDocuments, - reloadAll, - getAllSpecDocuments: (name: string) => getJson(`/api/specs/${encodeURIComponent(name)}/all`), - getAllArchivedSpecDocuments: (name: string) => getJson(`/api/specs/${encodeURIComponent(name)}/all/archived`), - getSpecTasksProgress: (name: string) => getJson(`/api/specs/${encodeURIComponent(name)}/tasks/progress`), - updateTaskStatus: (specName: string, taskId: string, status: 'pending' | 'in-progress' | 'completed') => putJson(`/api/specs/${encodeURIComponent(specName)}/tasks/${encodeURIComponent(taskId)}/status`, { status }), - approvalsAction: (id, action, body) => postJson(`/api/approvals/${encodeURIComponent(id)}/${action}`, body), - getApprovalContent: (id: string) => getJson(`/api/approvals/${encodeURIComponent(id)}/content`), - getApprovalSnapshots: (id: string) => getJson(`/api/approvals/${encodeURIComponent(id)}/snapshots`), - getApprovalSnapshot: (id: string, version: number) => getJson(`/api/approvals/${encodeURIComponent(id)}/snapshots/${version}`), - getApprovalDiff: (id: string, fromVersion: number, toVersion?: number | 'current') => { - const to = toVersion === undefined ? 'current' : toVersion; - return getJson(`/api/approvals/${encodeURIComponent(id)}/diff?from=${fromVersion}&to=${to}`); - }, - captureApprovalSnapshot: (id: string) => postJson(`/api/approvals/${encodeURIComponent(id)}/snapshot`, {}), - saveSpecDocument: (name: string, document: string, content: string) => putJson(`/api/specs/${encodeURIComponent(name)}/${encodeURIComponent(document)}`, { content }), - saveArchivedSpecDocument: (name: string, document: string, content: string) => putJson(`/api/specs/${encodeURIComponent(name)}/${encodeURIComponent(document)}/archived`, { content }), - archiveSpec: (name: string) => postJson(`/api/specs/${encodeURIComponent(name)}/archive`, {}), - unarchiveSpec: (name: string) => postJson(`/api/specs/${encodeURIComponent(name)}/unarchive`, {}), - getSteeringDocument: (name: string) => getJson(`/api/steering/${encodeURIComponent(name)}`), - saveSteeringDocument: (name: string, content: string) => putJson(`/api/steering/${encodeURIComponent(name)}`, { content }), - }), [specs, archivedSpecs, approvals, info, steeringDocuments, reloadAll]); + }, [subscribe, unsubscribe]); + + const value = useMemo(() => { + if (!projectId) { + // Return empty API when no project selected + return { + specs: [], + archivedSpecs: [], + approvals: [], + info: undefined, + steeringDocuments: undefined, + projectId: null, + reloadAll: async () => {}, + getAllSpecDocuments: async () => ({}), + getAllArchivedSpecDocuments: async () => ({}), + getSpecTasksProgress: async () => ({}), + updateTaskStatus: async () => ({ ok: false, status: 400 }), + approvalsAction: async () => ({ ok: false, status: 400 }), + getApprovalContent: async () => ({ content: '' }), + getApprovalSnapshots: async () => [], + getApprovalSnapshot: async () => ({} as any), + getApprovalDiff: async () => ({} as any), + captureApprovalSnapshot: async () => ({ success: false, message: 'No project selected' }), + saveSpecDocument: async () => ({ ok: false, status: 400 }), + saveArchivedSpecDocument: async () => ({ ok: false, status: 400 }), + archiveSpec: async () => ({ ok: false, status: 400 }), + unarchiveSpec: async () => ({ ok: false, status: 400 }), + getSteeringDocument: async () => ({ content: '', lastModified: '' }), + saveSteeringDocument: async () => ({ ok: false, status: 400 }), + }; + } + + const prefix = `/api/projects/${encodeURIComponent(projectId)}`; + + return { + specs, + archivedSpecs, + approvals, + info, + steeringDocuments, + projectId, + reloadAll, + getAllSpecDocuments: (name: string) => getJson(`${prefix}/specs/${encodeURIComponent(name)}/all`), + getAllArchivedSpecDocuments: (name: string) => getJson(`${prefix}/specs/${encodeURIComponent(name)}/all/archived`), + getSpecTasksProgress: (name: string) => getJson(`${prefix}/specs/${encodeURIComponent(name)}/tasks/progress`), + updateTaskStatus: (specName: string, taskId: string, status: 'pending' | 'in-progress' | 'completed') => + putJson(`${prefix}/specs/${encodeURIComponent(specName)}/tasks/${encodeURIComponent(taskId)}/status`, { status }), + approvalsAction: (id, action, body) => postJson(`${prefix}/approvals/${encodeURIComponent(id)}/${action}`, body), + getApprovalContent: (id: string) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/content`), + getApprovalSnapshots: (id: string) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/snapshots`), + getApprovalSnapshot: (id: string, version: number) => getJson(`${prefix}/approvals/${encodeURIComponent(id)}/snapshots/${version}`), + getApprovalDiff: (id: string, fromVersion: number, toVersion?: number | 'current') => { + const to = toVersion === undefined ? 'current' : toVersion; + return getJson(`${prefix}/approvals/${encodeURIComponent(id)}/diff?from=${fromVersion}&to=${to}`); + }, + captureApprovalSnapshot: (id: string) => postJson(`${prefix}/approvals/${encodeURIComponent(id)}/snapshot`, {}), + saveSpecDocument: (name: string, document: string, content: string) => + putJson(`${prefix}/specs/${encodeURIComponent(name)}/${encodeURIComponent(document)}`, { content }), + saveArchivedSpecDocument: (name: string, document: string, content: string) => + putJson(`${prefix}/specs/${encodeURIComponent(name)}/${encodeURIComponent(document)}/archived`, { content }), + archiveSpec: (name: string) => postJson(`${prefix}/specs/${encodeURIComponent(name)}/archive`, {}), + unarchiveSpec: (name: string) => postJson(`${prefix}/specs/${encodeURIComponent(name)}/unarchive`, {}), + getSteeringDocument: (name: string) => getJson(`${prefix}/steering/${encodeURIComponent(name)}`), + saveSteeringDocument: (name: string, content: string) => putJson(`${prefix}/steering/${encodeURIComponent(name)}`, { content }), + }; + }, [specs, archivedSpecs, approvals, info, steeringDocuments, projectId, reloadAll]); return {children}; } diff --git a/src/dashboard_frontend/src/modules/app/App.tsx b/src/dashboard_frontend/src/modules/app/App.tsx index d39e1c1..6e6dfd0 100644 --- a/src/dashboard_frontend/src/modules/app/App.tsx +++ b/src/dashboard_frontend/src/modules/app/App.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Routes, Route, NavLink, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate } from 'react-router-dom'; import { ThemeProvider, useTheme } from '../theme/ThemeProvider'; import { WebSocketProvider, useWs } from '../ws/WebSocketProvider'; +import { ProjectProvider, useProjects } from '../projects/ProjectProvider'; import { ApiProvider } from '../api/api'; import { HighlightStyles } from '../theme/HighlightStyles'; import { DashboardStatistics } from '../pages/DashboardStatistics'; @@ -11,26 +12,29 @@ import { SteeringPage } from '../pages/SteeringPage'; import { TasksPage } from '../pages/TasksPage'; import { ApprovalsPage } from '../pages/ApprovalsPage'; import { SpecViewerPage } from '../pages/SpecViewerPage'; -import { NotificationProvider, useNotifications } from '../notifications/NotificationProvider'; +import { NotificationProvider } from '../notifications/NotificationProvider'; import { VolumeControl } from '../notifications/VolumeControl'; import { useApi } from '../api/api'; import { LanguageSelector } from '../../components/LanguageSelector'; import { I18nErrorBoundary } from '../../components/I18nErrorBoundary'; +import { ProjectDropdown } from '../components/ProjectDropdown'; +import { PageNavigationSidebar } from '../components/PageNavigationSidebar'; -function Header() { +function Header({ toggleSidebar }: { toggleSidebar: () => void }) { const { t } = useTranslation(); const { theme, toggleTheme } = useTheme(); const { connected } = useWs(); + const { currentProject } = useProjects(); const { info } = useApi(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - + // Update the browser tab title when project info is loaded useEffect(() => { if (info?.projectName) { document.title = t('documentTitle', { projectName: info.projectName }); } }, [info?.projectName, t]); - + const toggleMobileMenu = () => { setMobileMenuOpen(!mobileMenuOpen); }; @@ -38,54 +42,47 @@ function Header() { const closeMobileMenu = () => { setMobileMenuOpen(false); }; - + return ( <>
-
-
-
-
Spec-Workflow-MCP
- {info?.version && ( - - v{info.version} - - )} -
- - {/* Desktop Navigation */} - +
+
+ {/* Page Navigation Sidebar Toggle Button */} + + + {/* Project Dropdown */} + + + {/* Version Badge */} + {info?.version && ( + + v{info.version} + + )}
- +
- + {/* Desktop Controls */}
- + - + - +
- {/* Mobile/Tablet Hamburger Menu Button */} -
- {/* Mobile/Tablet Slide-out Menu Overlay */} + {/* Mobile/Tablet Slide-out Controls Menu */} {mobileMenuOpen && ( -
- {/* Backdrop */}
- - {/* Sidebar */} -
e.stopPropagation()} >
- {/* Header */}
-
{t('mobile.menu')}
-
+
- {/* Navigation Links */} - - - {/* Mobile Controls */} -
+ {/* Controls Section */} +
{t('mobile.notificationVolume')}
- +
{t('language.select')}
- +
{t('mobile.theme')}
- - {/* Support Button */} +
- - {/* Version */} + {info?.version && (
@@ -252,36 +186,101 @@ function Header() { function AppInner() { const { initial } = useWs(); + const { currentProjectId } = useProjects(); + const [sidebarOpen, setSidebarOpen] = useState(true); // Default open on desktop + + const SIDEBAR_COLLAPSE_KEY = 'spec-workflow-sidebar-collapsed'; + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + try { + const stored = localStorage.getItem(SIDEBAR_COLLAPSE_KEY); + return stored ? JSON.parse(stored) : false; + } catch { + return false; + } + }); + + // Persist sidebar collapse state to localStorage + useEffect(() => { + try { + localStorage.setItem(SIDEBAR_COLLAPSE_KEY, JSON.stringify(sidebarCollapsed)); + } catch (error) { + console.error('Failed to save sidebar collapse state:', error); + } + }, [sidebarCollapsed]); + + const toggleSidebar = () => { + setSidebarOpen(!sidebarOpen); + }; + + const toggleSidebarCollapse = () => { + setSidebarCollapsed(!sidebarCollapsed); + }; + return ( - + -
-
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
+
+ {/* Page Navigation Sidebar */} + setSidebarOpen(false)} + onToggleCollapse={toggleSidebarCollapse} + /> +
+
+ +
+ {currentProjectId ? ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( +
+
+

+ No Projects Available +

+

+ Start MCP servers to see projects here. +

+
+ Run: spec-workflow-mcp /path/to/project +
+
+
+ )} +
+
); } +function AppWithProviders() { + const { currentProjectId } = useProjects(); + + return ( + + + + ); +} + export default function App() { return ( - - - + + + ); diff --git a/src/dashboard_frontend/src/modules/components/PageNavigationSidebar.tsx b/src/dashboard_frontend/src/modules/components/PageNavigationSidebar.tsx new file mode 100644 index 0000000..a8c1699 --- /dev/null +++ b/src/dashboard_frontend/src/modules/components/PageNavigationSidebar.tsx @@ -0,0 +1,228 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { NavLink, useLocation } from 'react-router-dom'; + +interface PageNavigationSidebarProps { + isOpen: boolean; + isCollapsed: boolean; + onClose: () => void; + onToggleCollapse: () => void; +} + +interface NavigationItem { + path: string; + labelKey: string; + icon: React.ReactNode; + end?: boolean; +} + +export function PageNavigationSidebar({ + isOpen, + isCollapsed, + onClose, + onToggleCollapse, +}: PageNavigationSidebarProps) { + const { t } = useTranslation(); + const location = useLocation(); + + // Handle ESC key to close sidebar on mobile + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + if (isOpen && window.innerWidth < 1024) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const navigationItems: NavigationItem[] = [ + { + path: '/', + labelKey: 'nav.statistics', + end: true, + icon: ( + + + + ), + }, + { + path: '/steering', + labelKey: 'nav.steering', + icon: ( + + + + ), + }, + { + path: '/specs', + labelKey: 'nav.specs', + icon: ( + + + + ), + }, + { + path: '/tasks', + labelKey: 'nav.tasks', + icon: ( + + + + ), + }, + { + path: '/approvals', + labelKey: 'nav.approvals', + icon: ( + + + + ), + }, + ]; + + // Desktop: Collapsible sidebar + // Mobile: Slide-in overlay + return ( + <> + {/* Backdrop for mobile - only show when open */} + {isOpen && ( +
+ )} + + {/* Sidebar */} +
+ {/* Header - Desktop collapse toggle */} +
+ {!isCollapsed && ( +

+ Spec Workflow MCP +

+ )} + +
+ + {/* Header - Mobile close button */} +
+

+ Spec Workflow MCP +

+ +
+ + {/* Navigation Items */} + +
+ + ); +} diff --git a/src/dashboard_frontend/src/modules/components/ProjectDropdown.tsx b/src/dashboard_frontend/src/modules/components/ProjectDropdown.tsx new file mode 100644 index 0000000..6720bb6 --- /dev/null +++ b/src/dashboard_frontend/src/modules/components/ProjectDropdown.tsx @@ -0,0 +1,178 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useProjects } from '../projects/ProjectProvider'; + +export function ProjectDropdown() { + const { t } = useTranslation(); + const { projects, currentProject, setCurrentProject, loading } = useProjects(); + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + + // Close dropdown when clicking outside or pressing ESC + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearchQuery(''); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + setIsOpen(false); + setSearchQuery(''); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + // Focus search input when dropdown opens + setTimeout(() => searchInputRef.current?.focus(), 0); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + // Filter projects based on search query + const filteredProjects = projects.filter(project => + project.projectName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleProjectSelect = (projectId: string) => { + setCurrentProject(projectId); + setIsOpen(false); + setSearchQuery(''); + }; + + const toggleDropdown = () => { + setIsOpen(!isOpen); + if (isOpen) { + setSearchQuery(''); + } + }; + + return ( +
+ {/* Dropdown Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
+ {/* Search Input */} +
+ setSearchQuery(e.target.value)} + placeholder={t('projects.search', 'Search projects...')} + className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 text-gray-900 dark:text-gray-100" + /> +
+ + {/* Project List */} +
+ {loading ? ( +
+ {t('projects.loading', 'Loading projects...')} +
+ ) : filteredProjects.length === 0 ? ( +
+ {searchQuery + ? t('projects.noResults', 'No projects found') + : t('projects.noProjects', 'No projects available')} +
+ ) : ( +
+ {filteredProjects.map((project) => { + const isCurrent = project.projectId === currentProject?.projectId; + return ( + + ); + })} +
+ )} +
+ + {/* Footer */} + {!loading && projects.length > 0 && ( +
+ {t('projects.count', { + count: projects.length, + defaultValue: `${projects.length} project(s)`, + })} +
+ )} +
+ )} +
+ ); +} diff --git a/src/dashboard_frontend/src/modules/components/SortDropdown.tsx b/src/dashboard_frontend/src/modules/components/SortDropdown.tsx index 89c3078..78e89cc 100644 --- a/src/dashboard_frontend/src/modules/components/SortDropdown.tsx +++ b/src/dashboard_frontend/src/modules/components/SortDropdown.tsx @@ -73,12 +73,21 @@ export function SortDropdown({ currentSort, currentOrder, onSortChange, sortOpti } }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setIsOpen(false); + } + }; + if (isOpen) { document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen]); diff --git a/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx b/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx index 48b319b..dd2c81e 100644 --- a/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/ApprovalsPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { ApiProvider, useApi, DocumentSnapshot, DiffResult } from '../api/api'; -import { useWs } from '../ws/WebSocketProvider'; +import { useApi, DocumentSnapshot, DiffResult } from '../api/api'; import { ApprovalsAnnotator, ApprovalComment } from '../approvals/ApprovalsAnnotator'; import { NotificationProvider } from '../notifications/NotificationProvider'; import { TextInputModal } from '../modals/TextInputModal'; @@ -668,12 +667,7 @@ function Content() { } export function ApprovalsPage() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/pages/DashboardStatistics.tsx b/src/dashboard_frontend/src/modules/pages/DashboardStatistics.tsx index 64fc4d4..bf93d8c 100644 --- a/src/dashboard_frontend/src/modules/pages/DashboardStatistics.tsx +++ b/src/dashboard_frontend/src/modules/pages/DashboardStatistics.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { ApiProvider, useApi } from '../api/api'; +import { useApi } from '../api/api'; import { useWs } from '../ws/WebSocketProvider'; function Content() { @@ -150,12 +150,7 @@ function Content() { } export function DashboardStatistics() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/pages/SpecViewerPage.tsx b/src/dashboard_frontend/src/modules/pages/SpecViewerPage.tsx index 8d1f9fd..95c6889 100644 --- a/src/dashboard_frontend/src/modules/pages/SpecViewerPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/SpecViewerPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { ApiProvider, useApi } from '../api/api'; -import { useWs } from '../ws/WebSocketProvider'; +import { useApi } from '../api/api'; import { useSearchParams } from 'react-router-dom'; import { Markdown } from '../markdown/Markdown'; import hljs from 'highlight.js/lib/common'; @@ -137,12 +136,7 @@ function Content() { } export function SpecViewerPage() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/pages/SpecsPage.tsx b/src/dashboard_frontend/src/modules/pages/SpecsPage.tsx index bfabd78..1d38e21 100644 --- a/src/dashboard_frontend/src/modules/pages/SpecsPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/SpecsPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; -import { ApiProvider, useApi } from '../api/api'; -import { useWs } from '../ws/WebSocketProvider'; +import { useApi } from '../api/api'; import { Markdown } from '../markdown/Markdown'; import { MarkdownEditor } from '../editor/MarkdownEditor'; import { ConfirmationModal } from '../modals/ConfirmationModal'; @@ -133,15 +132,31 @@ function SpecModal({ spec, isOpen, onClose, isArchived }: { spec: any; isOpen: b // Check for unsaved changes before closing const handleClose = useCallback(() => { const hasUnsaved = editContent !== content && viewMode === 'editor'; - + if (hasUnsaved) { setConfirmCloseModalOpen(true); return; } - + onClose(); }, [editContent, content, viewMode, onClose]); + // Handle ESC key to close modal + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleClose]); + const handleConfirmClose = () => { onClose(); }; @@ -799,12 +814,7 @@ function Content() { } export function SpecsPage() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/pages/SteeringPage.tsx b/src/dashboard_frontend/src/modules/pages/SteeringPage.tsx index 5aabd47..37a3256 100644 --- a/src/dashboard_frontend/src/modules/pages/SteeringPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/SteeringPage.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; -import { ApiProvider, useApi } from '../api/api'; -import { useWs } from '../ws/WebSocketProvider'; +import { useApi } from '../api/api'; import { Markdown } from '../markdown/Markdown'; import { MarkdownEditor } from '../editor/MarkdownEditor'; import { ConfirmationModal } from '../modals/ConfirmationModal'; @@ -103,15 +102,31 @@ function SteeringModal({ document, isOpen, onClose }: { document: SteeringDocume // Check for unsaved changes before closing const handleClose = useCallback(() => { const hasUnsaved = editContent !== content && viewMode === 'editor'; - + if (hasUnsaved) { setConfirmCloseModalOpen(true); return; } - + onClose(); }, [editContent, content, viewMode, onClose]); + // Handle ESC key to close modal + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + } + }; + + if (isOpen) { + window.document.addEventListener('keydown', handleKeyDown); + } + + return () => window.document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, handleClose]); + const handleConfirmClose = () => { onClose(); }; @@ -465,10 +480,5 @@ function Content() { } export function SteeringPage() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/pages/TasksPage.tsx b/src/dashboard_frontend/src/modules/pages/TasksPage.tsx index 50b131d..87b92c7 100644 --- a/src/dashboard_frontend/src/modules/pages/TasksPage.tsx +++ b/src/dashboard_frontend/src/modules/pages/TasksPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; -import { ApiProvider, useApi } from '../api/api'; +import { useApi } from '../api/api'; import { useWs } from '../ws/WebSocketProvider'; import { useSearchParams } from 'react-router-dom'; import { useNotifications } from '../notifications/NotificationProvider'; @@ -42,12 +42,22 @@ function SearchableSpecDropdown({ specs, selected, onSelect }: { specs: any[]; s } }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setIsOpen(false); + setSearch(''); + } + }; + if (isOpen) { document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen]); @@ -252,12 +262,21 @@ function StatusPill({ } }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setIsOpen(false); + } + }; + if (isOpen) { document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen]); @@ -1432,12 +1451,7 @@ function Content() { } export function TasksPage() { - const { initial } = useWs(); - return ( - - - - ); + return ; } diff --git a/src/dashboard_frontend/src/modules/projects/ProjectProvider.tsx b/src/dashboard_frontend/src/modules/projects/ProjectProvider.tsx new file mode 100644 index 0000000..7adadcb --- /dev/null +++ b/src/dashboard_frontend/src/modules/projects/ProjectProvider.tsx @@ -0,0 +1,125 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'; + +export interface Project { + projectId: string; + projectName: string; + projectPath: string; +} + +interface ProjectContextType { + projects: Project[]; + currentProjectId: string | null; + currentProject: Project | null; + setCurrentProject: (projectId: string) => void; + refreshProjects: () => Promise; + loading: boolean; +} + +const ProjectContext = createContext(undefined); + +const STORAGE_KEY = 'spec-workflow-current-project'; + +export function ProjectProvider({ children }: { children: React.ReactNode }) { + const [projects, setProjects] = useState([]); + const [currentProjectId, setCurrentProjectId] = useState(() => { + // Initialize from localStorage if available + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } + }); + const [loading, setLoading] = useState(true); + + // Use ref to track current project without dependency issues + const currentProjectIdRef = useRef(currentProjectId); + const hasInitializedRef = useRef(false); + + // Sync ref and localStorage when state changes + useEffect(() => { + currentProjectIdRef.current = currentProjectId; + if (currentProjectId) { + try { + localStorage.setItem(STORAGE_KEY, currentProjectId); + } catch (error) { + console.error('Failed to save project selection:', error); + } + } else { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.error('Failed to remove project selection:', error); + } + } + }, [currentProjectId]); + + // Fetch projects from API + const fetchProjects = useCallback(async () => { + try { + const response = await fetch('/api/projects/list'); + if (response.ok) { + const data = await response.json() as Project[]; + setProjects(data); + + const currentId = currentProjectIdRef.current; + + // Only auto-select first project on INITIAL load (not during polling) + if (!currentId && !hasInitializedRef.current && data.length > 0) { + setCurrentProjectId(data[0].projectId); + } + + // If current project no longer exists, select first available + if (currentId && !data.find(p => p.projectId === currentId)) { + console.warn('Current project no longer exists, selecting first available'); + setCurrentProjectId(data.length > 0 ? data[0].projectId : null); + } + + // Mark as initialized after first successful fetch + hasInitializedRef.current = true; + } + } catch (error) { + console.error('Failed to fetch projects:', error); + } finally { + setLoading(false); + } + }, []); // No dependencies - use refs instead + + // Initial fetch + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + // Poll for updates every 2.5 seconds + useEffect(() => { + const interval = setInterval(() => { + fetchProjects(); + }, 2500); + + return () => clearInterval(interval); + }, [fetchProjects]); + + const setCurrentProject = useCallback((projectId: string) => { + setCurrentProjectId(projectId); + }, []); + + const currentProject = useMemo(() => { + return projects.find(p => p.projectId === currentProjectId) || null; + }, [projects, currentProjectId]); + + const value = useMemo(() => ({ + projects, + currentProjectId, + currentProject, + setCurrentProject, + refreshProjects: fetchProjects, + loading + }), [projects, currentProjectId, currentProject, setCurrentProject, fetchProjects, loading]); + + return {children}; +} + +export function useProjects(): ProjectContextType { + const ctx = useContext(ProjectContext); + if (!ctx) throw new Error('useProjects must be used within ProjectProvider'); + return ctx; +} diff --git a/src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx b/src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx index 3511727..ae421b1 100644 --- a/src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx +++ b/src/dashboard_frontend/src/modules/ws/WebSocketProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; type InitialPayload = { specs: any[]; @@ -14,60 +14,114 @@ type WsContextType = { const WsContext = createContext(undefined); -export function WebSocketProvider({ children }: { children: React.ReactNode }) { +interface WebSocketProviderProps { + children: React.ReactNode; + projectId: string | null; +} + +export function WebSocketProvider({ children, projectId }: WebSocketProviderProps) { const [connected, setConnected] = useState(false); const [initial, setInitial] = useState(undefined); const wsRef = useRef(null); const eventHandlersRef = useRef void>>>(new Map()); + const retryTimerRef = useRef(null); + const currentProjectIdRef = useRef(null); - useEffect(() => { - let retryTimer: any; - const connect = () => { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${location.host}/ws`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => setConnected(true); - ws.onclose = () => { - setConnected(false); - retryTimer = setTimeout(connect, 2000); - }; - ws.onerror = () => { - // noop; close will handle retry - }; - ws.onmessage = (ev) => { - try { - const msg = JSON.parse(ev.data); - if (msg.type === 'initial') { - setInitial({ specs: msg.data?.specs || [], approvals: msg.data?.approvals || [] }); - } else { - // Forward all websocket messages to subscribers - const handlers = eventHandlersRef.current.get(msg.type); - if (handlers) { - handlers.forEach(handler => handler(msg.data)); - } + const connectToWebSocket = useCallback((targetProjectId: string | null) => { + // Close existing connection if any + if (wsRef.current) { + wsRef.current.onclose = null; // Prevent reconnection + wsRef.current.close(); + wsRef.current = null; + } + + // Clear any pending retry + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + + // Build WebSocket URL with projectId query parameter + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = targetProjectId + ? `${protocol}//${location.host}/ws?projectId=${encodeURIComponent(targetProjectId)}` + : `${protocol}//${location.host}/ws`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + currentProjectIdRef.current = targetProjectId; + + ws.onopen = () => setConnected(true); + + ws.onclose = () => { + setConnected(false); + // Only retry if we're still on the same project + if (currentProjectIdRef.current === targetProjectId) { + retryTimerRef.current = setTimeout(() => { + connectToWebSocket(targetProjectId); + }, 2000); + } + }; + + ws.onerror = () => { + // noop; close will handle retry + }; + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + + // Handle initial message + if (msg.type === 'initial' && msg.projectId === targetProjectId) { + setInitial({ specs: msg.data?.specs || [], approvals: msg.data?.approvals || [] }); + } + // Handle projects-update (global message) + else if (msg.type === 'projects-update') { + const handlers = eventHandlersRef.current.get('projects-update'); + if (handlers) { + handlers.forEach(handler => handler(msg.data)); + } + } + // Handle project-scoped messages + else if (msg.projectId === targetProjectId) { + const handlers = eventHandlersRef.current.get(msg.type); + if (handlers) { + handlers.forEach(handler => handler(msg.data)); } - } catch { - // ignore } - }; + } catch { + // ignore + } }; - connect(); + }, []); + + // Connect/reconnect when projectId changes + useEffect(() => { + if (projectId) { + // Clear initial data when switching projects + setInitial(undefined); + connectToWebSocket(projectId); + } + return () => { - clearTimeout(retryTimer); - wsRef.current?.close(); + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + } + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + } }; - }, []); + }, [projectId, connectToWebSocket]); - const subscribe = (eventType: string, handler: (data: any) => void) => { + const subscribe = useCallback((eventType: string, handler: (data: any) => void) => { if (!eventHandlersRef.current.has(eventType)) { eventHandlersRef.current.set(eventType, new Set()); } eventHandlersRef.current.get(eventType)!.add(handler); - }; + }, []); - const unsubscribe = (eventType: string, handler: (data: any) => void) => { + const unsubscribe = useCallback((eventType: string, handler: (data: any) => void) => { const handlers = eventHandlersRef.current.get(eventType); if (handlers) { handlers.delete(handler); @@ -75,9 +129,15 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { eventHandlersRef.current.delete(eventType); } } - }; + }, []); + + const value = useMemo(() => ({ + connected, + initial, + subscribe, + unsubscribe + }), [connected, initial, subscribe, unsubscribe]); - const value = useMemo(() => ({ connected, initial, subscribe, unsubscribe }), [connected, initial]); return {children}; } diff --git a/src/index.ts b/src/index.ts index 5b7f209..99713a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,7 @@ #!/usr/bin/env node import { SpecWorkflowMCPServer } from './server.js'; -import { DashboardServer } from './dashboard/server.js'; -import { DASHBOARD_TEST_MESSAGE } from './dashboard/utils.js'; +import { MultiProjectDashboardServer } from './dashboard/multi-server.js'; import { homedir } from 'os'; import { loadConfigFile, mergeConfigs, SpecWorkflowConfig } from './config.js'; import { WorkspaceInitializer } from './core/workspace-initializer.js'; @@ -41,14 +40,14 @@ MODES OF OPERATION: 1. MCP Server Only (default): spec-workflow-mcp spec-workflow-mcp ~/my-project - + Starts MCP server without dashboard. Dashboard can be started separately. 2. MCP Server with Auto-Started Dashboard: spec-workflow-mcp --AutoStartDashboard spec-workflow-mcp --AutoStartDashboard --port 3456 spec-workflow-mcp ~/my-project --AutoStartDashboard - + Starts MCP server and automatically launches dashboard in browser. Note: Server and dashboard shut down when MCP client disconnects. @@ -56,7 +55,7 @@ MODES OF OPERATION: spec-workflow-mcp --dashboard spec-workflow-mcp --dashboard --port 3456 spec-workflow-mcp ~/my-project --dashboard - + Runs only the web dashboard without MCP server. EXAMPLES: @@ -98,9 +97,9 @@ function expandTildePath(path: string): string { return path; } -function parseArguments(args: string[]): { - projectPath: string; - isDashboardMode: boolean; +function parseArguments(args: string[]): { + projectPath: string; + isDashboardMode: boolean; autoStartDashboard: boolean; port?: number; lang?: string; @@ -110,7 +109,7 @@ function parseArguments(args: string[]): { const autoStartDashboard = args.includes('--AutoStartDashboard'); let customPort: number | undefined; let configPath: string | undefined; - + // Check for invalid flags const validFlags = ['--dashboard', '--AutoStartDashboard', '--port', '--config', '--help', '-h']; for (const arg of args) { @@ -125,11 +124,11 @@ function parseArguments(args: string[]): { } } } - + // Parse --port parameter (supports --port 3000 and --port=3000 formats) for (let i = 0; i < args.length; i++) { const arg = args[i]; - + if (arg.startsWith('--port=')) { // Handle --port=3000 format const portStr = arg.split('=')[1]; @@ -161,11 +160,11 @@ function parseArguments(args: string[]): { throw new Error('--port parameter requires a value (e.g., --port 3000)'); } } - + // Parse --config parameter (supports --config path and --config=path formats) for (let i = 0; i < args.length; i++) { const arg = args[i]; - + if (arg.startsWith('--config=')) { // Handle --config=path format configPath = arg.split('=')[1]; @@ -180,7 +179,7 @@ function parseArguments(args: string[]): { throw new Error('--config parameter requires a value (e.g., --config ./config.toml)'); } } - + // Get project path (filter out flags and their values) const filteredArgs = args.filter((arg, index) => { if (arg === '--dashboard') return false; @@ -193,36 +192,36 @@ function parseArguments(args: string[]): { if (index > 0 && (args[index - 1] === '--port' || args[index - 1] === '--config')) return false; return true; }); - + const rawProjectPath = filteredArgs[0] || process.cwd(); const projectPath = expandTildePath(rawProjectPath); - + // Warn if no explicit path was provided and we're using cwd if (!filteredArgs[0] && !isDashboardMode) { console.warn(`Warning: No project path specified, using current directory: ${projectPath}`); console.warn('Consider specifying an explicit path for better clarity.'); } - + return { projectPath, isDashboardMode, autoStartDashboard, port: customPort, lang: undefined, configPath }; } async function main() { try { const args = process.argv.slice(2); - + // Check for help flag if (args.includes('--help') || args.includes('-h')) { showHelp(); process.exit(0); } - + // Parse command-line arguments first to get initial project path const cliArgs = parseArguments(args); let projectPath = cliArgs.projectPath; - + // Load config file (custom path or default location) const configResult = loadConfigFile(projectPath, cliArgs.configPath); - + if (configResult.error) { // If custom config was specified but failed, this is fatal if (cliArgs.configPath) { @@ -235,7 +234,7 @@ async function main() { } else if (configResult.config && configResult.configPath) { console.error(`Loaded config from: ${configResult.configPath}`); } - + // Convert CLI args to config format const cliConfig: SpecWorkflowConfig = { projectDir: cliArgs.projectPath !== process.cwd() ? cliArgs.projectPath : undefined, @@ -244,10 +243,10 @@ async function main() { port: cliArgs.port, lang: cliArgs.lang }; - + // Merge configs (CLI overrides file config) const finalConfig = mergeConfigs(configResult.config, cliConfig); - + // Apply final configuration if (finalConfig.projectDir) { projectPath = finalConfig.projectDir; @@ -256,111 +255,72 @@ async function main() { const autoStartDashboard = finalConfig.autoStartDashboard || false; const port = finalConfig.port; const lang = finalConfig.lang; - + if (isDashboardMode) { - // Dashboard only mode - console.error(`Starting Spec Workflow Dashboard for project: ${projectPath}`); + // Dashboard only mode - use new multi-project dashboard + console.error(`Starting Unified Multi-Project Dashboard`); if (port) { console.error(`Using custom port: ${port}`); } - - // Initialize workspace directories and templates - const __dirname = dirname(fileURLToPath(import.meta.url)); - const packageJsonPath = join(__dirname, '..', 'package.json'); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); - const workspaceInitializer = new WorkspaceInitializer(projectPath, packageJson.version); - await workspaceInitializer.initializeWorkspace(); - - const dashboardServer = new DashboardServer({ - projectPath, + + const dashboardServer = new MultiProjectDashboardServer({ autoOpen: true, port }); - + try { const dashboardUrl = await dashboardServer.start(); console.error(`Dashboard started at: ${dashboardUrl}`); + console.error('Projects will automatically appear as MCP servers register.'); console.error('Press Ctrl+C to stop the dashboard'); } catch (error: any) { - if (error.message.includes('already in use') && port) { - // Check if it's an existing dashboard - try { - const response = await fetch(`http://localhost:${port}/api/test`, { - method: 'GET', - signal: AbortSignal.timeout(1000) - }); - - if (response.ok) { - const data = await response.json() as { message?: string }; - if (data.message === DASHBOARD_TEST_MESSAGE) { - console.error(`Dashboard already running at http://localhost:${port}`); - console.error('Another dashboard instance is already serving this project.'); - console.error('Please close the existing instance or use a different port.'); - process.exit(0); - } - } - } catch { - // Not our dashboard - } - console.error(`Error: Port ${port} is already in use by another service.`); - console.error('Please choose a different port or stop the service using this port.'); - } else { - console.error(`Failed to start dashboard: ${error.message}`); - } + console.error(`Failed to start dashboard: ${error.message}`); process.exit(1); } - + // Handle graceful shutdown const shutdown = async () => { console.error('\nShutting down dashboard...'); await dashboardServer.stop(); process.exit(0); }; - + process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); - + // Keep the process running process.stdin.resume(); - + } else { - // MCP server mode (with optional auto-start dashboard) + // MCP server mode (with optional auto-start dashboard - deprecated) console.error(`Starting Spec Workflow MCP Server for project: ${projectPath}`); console.error(`Working directory: ${process.cwd()}`); - + const server = new SpecWorkflowMCPServer(); - - // Initialize with dashboard options + + // Initialize with dashboard options (will show deprecation warning if used) const dashboardOptions = autoStartDashboard ? { autoStart: true, port: port } : undefined; - + await server.initialize(projectPath, dashboardOptions, lang); - - // Start monitoring for dashboard session - server.startDashboardMonitoring(); - - // Inform user about MCP server lifecycle - if (autoStartDashboard) { - console.error('\nMCP server is running. The server and dashboard will shut down when the MCP client disconnects.'); - } - + // Handle graceful shutdown process.on('SIGINT', async () => { await server.stop(); process.exit(0); }); - + process.on('SIGTERM', async () => { await server.stop(); process.exit(0); }); } - + } catch (error: any) { console.error('Error:', error.message); - + // Provide additional context for common path-related issues if (error.message.includes('ENOENT') || error.message.includes('path') || error.message.includes('directory')) { console.error('\nProject path troubleshooting:'); @@ -369,7 +329,7 @@ async function main() { console.error('- Check that the path doesn\'t contain special characters that need escaping'); console.error(`- Current working directory: ${process.cwd()}`); } - + process.exit(1); } } diff --git a/src/server.ts b/src/server.ts index cf53ff1..e01771f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { - CallToolRequestSchema, +import { + CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, @@ -11,10 +11,9 @@ import { import { registerTools, handleToolCall } from './tools/index.js'; import { registerPrompts, handlePromptList, handlePromptGet } from './prompts/index.js'; import { validateProjectPath } from './core/path-utils.js'; -import { DashboardServer } from './dashboard/server.js'; -import { DASHBOARD_TEST_MESSAGE } from './dashboard/utils.js'; import { SessionManager } from './core/session-manager.js'; import { WorkspaceInitializer } from './core/workspace-initializer.js'; +import { ProjectRegistry } from './core/project-registry.js'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -27,28 +26,26 @@ export interface DashboardStartOptions { export class SpecWorkflowMCPServer { private server: Server; private projectPath!: string; - private dashboardServer?: DashboardServer; - private dashboardUrl?: string; + private projectRegistry: ProjectRegistry; private sessionManager?: SessionManager; private lang?: string; - private dashboardMonitoringInterval?: NodeJS.Timeout; constructor() { // Get version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); - + // Get all registered tools and prompts const tools = registerTools(); const prompts = registerPrompts(); - + // Create tools capability object with each tool name const toolsCapability = tools.reduce((acc, tool) => { acc[tool.name] = {}; return acc; }, {} as Record); - + this.server = new Server({ name: 'spec-workflow-mcp', version: packageJson.version @@ -60,121 +57,83 @@ export class SpecWorkflowMCPServer { } } }); + + this.projectRegistry = new ProjectRegistry(); } async initialize(projectPath: string, dashboardOptions?: DashboardStartOptions, lang?: string) { this.projectPath = projectPath; this.lang = lang; + try { // Validate project path await validateProjectPath(this.projectPath); - + // Initialize workspace const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); const workspaceInitializer = new WorkspaceInitializer(this.projectPath, packageJson.version); await workspaceInitializer.initializeWorkspace(); - + // Initialize session manager this.sessionManager = new SessionManager(this.projectPath); - - // Start dashboard if requested + + // Deprecation warning for --AutoStartDashboard if (dashboardOptions?.autoStart) { - try { - this.dashboardServer = new DashboardServer({ - projectPath: this.projectPath, - autoOpen: true, // Auto-open browser when dashboard is auto-started - port: dashboardOptions.port - }); - this.dashboardUrl = await this.dashboardServer.start(); - - // Create session tracking (overwrites any existing session.json) - await this.sessionManager.createSession(this.dashboardUrl); - - // Log dashboard startup info - console.error(`Dashboard auto-started at: ${this.dashboardUrl}`); - } catch (dashboardError: any) { - // Check if it's a port conflict error - if (dashboardError.message.includes('already in use') && dashboardOptions.port) { - // Try to check if an existing dashboard is running - console.error(`Port ${dashboardOptions.port} is already in use, checking for existing dashboard...`); - - try { - const response = await fetch(`http://localhost:${dashboardOptions.port}/api/test`, { - method: 'GET', - signal: AbortSignal.timeout(1000) - }); - - if (response.ok) { - const data = await response.json() as { message?: string }; - if (data.message === DASHBOARD_TEST_MESSAGE) { - // Existing dashboard found, use it - this.dashboardUrl = `http://localhost:${dashboardOptions.port}`; - console.error(`Found existing dashboard at ${this.dashboardUrl} - connecting to it`); - - // Update session with existing dashboard URL - await this.sessionManager.createSession(this.dashboardUrl); - } else { - console.error(`Port ${dashboardOptions.port} is in use by another service (not our dashboard)`); - console.error('MCP server will continue without dashboard functionality'); - } - } else { - console.error(`Port ${dashboardOptions.port} is in use but service is not responding`); - console.error('MCP server will continue without dashboard functionality'); - } - } catch { - console.error(`Port ${dashboardOptions.port} is in use by another service`); - console.error('MCP server will continue without dashboard functionality'); - } - } else { - // Some other dashboard error - console.error(`Failed to start dashboard: ${dashboardError.message}`); - console.error('MCP server will continue without dashboard functionality'); - } - - // Clear dashboard server reference since we didn't successfully create one - this.dashboardServer = undefined; - } + console.error(''); + console.error('⚠️ DEPRECATION WARNING:'); + console.error(' --AutoStartDashboard is deprecated in the new multi-project architecture.'); + console.error(' Please run a single dashboard server separately:'); + console.error(' '); + console.error(' spec-workflow-mcp --dashboard --port 5000'); + console.error(' '); + console.error(' Then start MCP servers without the dashboard flag.'); + console.error(' Projects will automatically appear in the unified dashboard.'); + console.error(''); } - + + // Register this project in the global registry + const projectId = await this.projectRegistry.registerProject(this.projectPath, process.pid); + console.error(`Project registered: ${projectId}`); + // Create context for tools const context = { projectPath: this.projectPath, - dashboardUrl: this.dashboardUrl, + dashboardUrl: undefined, // No per-project dashboard URL sessionManager: this.sessionManager, lang: this.lang }; - + // Register handlers this.setupHandlers(context); - + // Connect to stdio transport const transport = new StdioServerTransport(); - + // Handle client disconnection - exit gracefully when transport closes transport.onclose = async () => { await this.stop(); process.exit(0); }; - + await this.server.connect(transport); - + // Monitor stdin for client disconnection (additional safety net) process.stdin.on('end', async () => { await this.stop(); process.exit(0); }); - + // Handle stdin errors process.stdin.on('error', async (error) => { console.error('stdin error:', error); await this.stop(); process.exit(1); }); - + // MCP server initialized successfully - + } catch (error) { throw error; } @@ -188,12 +147,7 @@ export class SpecWorkflowMCPServer { this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { - // Create dynamic context with current dashboard URL - const dynamicContext = { - ...context, - dashboardUrl: this.dashboardUrl - }; - return await handleToolCall(request.params.name, request.params.arguments || {}, dynamicContext); + return await handleToolCall(request.params.name, request.params.arguments || {}, context); } catch (error: any) { throw new McpError(ErrorCode.InternalError, error.message); } @@ -210,15 +164,10 @@ export class SpecWorkflowMCPServer { this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { try { - // Create dynamic context with current dashboard URL - const dynamicContext = { - ...context, - dashboardUrl: this.dashboardUrl - }; return await handlePromptGet( request.params.name, request.params.arguments || {}, - dynamicContext + context ); } catch (error: any) { throw new McpError(ErrorCode.InternalError, error.message); @@ -226,76 +175,16 @@ export class SpecWorkflowMCPServer { }); } - startDashboardMonitoring() { - // Check immediately - this.checkForDashboardSession(); - - // Then check every 2 seconds - this.dashboardMonitoringInterval = setInterval(() => { - this.checkForDashboardSession(); - }, 2000); - } - - private async checkForDashboardSession() { - if (!this.sessionManager) { - return; // No session manager - } - - try { - const dashboardUrl = await this.sessionManager.getDashboardUrl(); - if (dashboardUrl && dashboardUrl !== this.dashboardUrl) { - // Test if the dashboard is actually reachable - const isReachable = await this.testDashboardConnection(dashboardUrl); - if (isReachable) { - this.dashboardUrl = dashboardUrl; - // Update context for tools that might need dashboard URL - // Note: Dashboard URL is now available to MCP tools - } - } else if (this.dashboardUrl) { - // We have a dashboard URL, but let's verify it's still reachable - const isReachable = await this.testDashboardConnection(this.dashboardUrl); - if (!isReachable) { - // Dashboard is no longer reachable, clear it so we can discover a new one - this.dashboardUrl = undefined; - } - } - } catch (error) { - // Session file doesn't exist yet, continue monitoring - if (this.dashboardUrl) { - // Clear stale dashboard URL if session file is gone - this.dashboardUrl = undefined; - } - } - } - - private async testDashboardConnection(url: string): Promise { - try { - // Try to fetch the dashboard's test endpoint with a short timeout - const response = await fetch(`${url}/api/test`, { - method: 'GET', - signal: AbortSignal.timeout(1000) // 1 second timeout - }); - return response.ok; - } catch (error) { - // Connection failed - return false; - } - } - async stop() { try { - // Stop dashboard monitoring - if (this.dashboardMonitoringInterval) { - clearInterval(this.dashboardMonitoringInterval); - this.dashboardMonitoringInterval = undefined; - } - - // Stop dashboard - if (this.dashboardServer) { - await this.dashboardServer.stop(); - this.dashboardServer = undefined; + // Unregister from global registry + try { + await this.projectRegistry.unregisterProject(this.projectPath); + console.error('Project unregistered from global registry'); + } catch (error) { + // Ignore errors during cleanup } - + // Stop MCP server await this.server.close(); } catch (error) { @@ -303,8 +192,4 @@ export class SpecWorkflowMCPServer { // Continue with shutdown even if there are errors } } - - getDashboardUrl(): string | undefined { - return this.dashboardUrl; - } } \ No newline at end of file