diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index 75030b90..0fcd1e4f 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -41,7 +41,8 @@ runs: # Use npm install instead of npm ci to correctly resolve platform-specific # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) # Skip scripts to avoid electron-builder install-app-deps which uses too much memory - run: npm install --ignore-scripts + # Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms + run: npm install --ignore-scripts --force - name: Install Linux native bindings shell: bash diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 39d5a257..d6904979 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -25,7 +25,7 @@ jobs: cache-dependency-path: package-lock.json - name: Install dependencies - run: npm install --ignore-scripts + run: npm install --ignore-scripts --force - name: Check formatting run: npm run format:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c71a884..50023666 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,11 @@ jobs: with: check-lockfile: 'true' + - name: Install RPM build tools (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: sudo apt-get update && sudo apt-get install -y rpm + - name: Build Electron app (macOS) if: matrix.os == 'macos-latest' shell: bash @@ -73,7 +78,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: linux-builds - path: apps/ui/release/*.{AppImage,deb} + path: apps/ui/release/*.{AppImage,deb,rpm} retention-days: 30 upload: @@ -104,8 +109,8 @@ jobs: uses: softprops/action-gh-release@v2 with: files: | - artifacts/macos-builds/* - artifacts/windows-builds/* - artifacts/linux-builds/* + artifacts/macos-builds/*.{dmg,zip,blockmap} + artifacts/windows-builds/*.{exe,blockmap} + artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 40664601..d46d1284 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,10 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali ## Environment Variables - `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth) +- `HOST` - Host to bind server to (default: 0.0.0.0) +- `HOSTNAME` - Hostname for user-facing URLs (default: localhost) - `PORT` - Server port (default: 3008) - `DATA_DIR` - Data storage directory (default: ./data) - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory - `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing +- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost) diff --git a/README.md b/README.md index 8bfd2a0a..3f9889fc 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - [Quick Start](#quick-start) - [How to Run](#how-to-run) - [Development Mode](#development-mode) + - [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users) - [Building for Production](#building-for-production) - [Testing](#testing) - [Linting](#linting) @@ -101,11 +102,9 @@ In the Discord, you can: ### Prerequisites -- **Node.js 18+** (tested with Node.js 22) +- **Node.js 22+** (required: >=22.0.0 <23.0.0) - **npm** (comes with Node.js) -- **Authentication** (choose one): - - **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically - - **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/)) +- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models. ### Quick Start @@ -117,30 +116,14 @@ cd automaker # 2. Install dependencies npm install -# 3. Build shared packages (can be skipped - npm run dev does it automatically) -npm run build:packages - -# 4. Start Automaker +# 3. Start Automaker npm run dev # Choose between: # 1. Web Application (browser at localhost:3007) # 2. Desktop Application (Electron - recommended) ``` -**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: - -- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically -- Enter an **API key** directly in the wizard - -If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually: - -```bash -# Option A: Environment variable -export ANTHROPIC_API_KEY="sk-ant-..." - -# Option B: Create .env file in project root -echo "ANTHROPIC_API_KEY=sk-ant-..." > .env -``` +**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically. **For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes. @@ -179,6 +162,40 @@ npm run dev:electron:wsl:gpu npm run dev:web ``` +### Interactive TUI Launcher (Recommended for New Users) + +For a user-friendly interactive menu, use the built-in TUI launcher script: + +```bash +# Show interactive menu with all launch options +./start-automaker.sh + +# Or launch directly without menu +./start-automaker.sh web # Web browser +./start-automaker.sh electron # Desktop app +./start-automaker.sh electron-debug # Desktop + DevTools + +# Additional options +./start-automaker.sh --help # Show all available options +./start-automaker.sh --version # Show version information +./start-automaker.sh --check-deps # Verify project dependencies +./start-automaker.sh --no-colors # Disable colored output +./start-automaker.sh --no-history # Don't remember last choice +``` + +**Features:** + +- 🎨 Beautiful terminal UI with gradient colors and ASCII art +- ⌨️ Interactive menu (press 1-3 to select, Q to exit) +- 💾 Remembers your last choice +- ✅ Pre-flight checks (validates Node.js, npm, dependencies) +- 📏 Responsive layout (adapts to terminal size) +- ⏱️ 30-second timeout for hands-free selection +- 🌐 Cross-shell compatible (bash/zsh) + +**History File:** +Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs. + ### Building for Production #### Web Application @@ -197,11 +214,30 @@ npm run build:electron # Platform-specific builds npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64) npm run build:electron:win # Windows (NSIS installer, x64) -npm run build:electron:linux # Linux (AppImage + DEB, x64) +npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64) # Output directory: apps/ui/release/ ``` +**Linux Distribution Packages:** + +- **AppImage**: Universal format, works on any Linux distribution +- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS +- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE + +**Installing on Fedora/RHEL:** + +```bash +# Download the RPM package +wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker--x86_64.rpm + +# Install with dnf (Fedora) +sudo dnf install ./Automaker--x86_64.rpm + +# Or with yum (RHEL/CentOS) +sudo yum localinstall ./Automaker--x86_64.rpm +``` + #### Docker Deployment Docker provides the most secure way to run Automaker by isolating it from your host filesystem. @@ -220,16 +256,9 @@ docker-compose logs -f docker-compose down ``` -##### Configuration +##### Authentication -Create a `.env` file in the project root if using API key authentication: - -```bash -# Optional: Anthropic API key (not needed if using Claude CLI authentication) -ANTHROPIC_API_KEY=sk-ant-... -``` - -**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below. +Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below). ##### Working with Projects (Host Directory Access) @@ -243,9 +272,9 @@ services: - /path/to/your/project:/projects/your-project ``` -##### Claude CLI Authentication (Optional) +##### Claude CLI Authentication -To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory: +Mount your Claude CLI config directory to use your authenticated CLI credentials: ```yaml services: @@ -343,10 +372,6 @@ npm run lint ### Environment Configuration -#### Authentication (if not using Claude Code CLI) - -- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI) - #### Optional - Server - `PORT` - Server port (default: 3008) @@ -357,49 +382,22 @@ npm run lint - `AUTOMAKER_API_KEY` - Optional API authentication for the server - `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory -- `CORS_ORIGIN` - CORS policy (default: \*) +- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only) #### Optional - Development - `VITE_SKIP_ELECTRON` - Skip Electron in dev mode - `OPEN_DEVTOOLS` - Auto-open DevTools in Electron +- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI) ### Authentication Setup -#### Option 1: Claude Code CLI (Recommended) +Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription. Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart). Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed! -#### Option 2: Direct API Key - -If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods: - -##### 2a. Shell Configuration - -Add to your `~/.bashrc` or `~/.zshrc`: - -```bash -export ANTHROPIC_API_KEY="sk-ant-..." -``` - -Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`). - -##### 2b. .env File - -Create a `.env` file in the project root (gitignored): - -```bash -ANTHROPIC_API_KEY=sk-ant-... -PORT=3008 -DATA_DIR=./data -``` - -##### 2c. In-App Storage - -The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory. - ## Features ### Core Workflow @@ -508,20 +506,24 @@ Automaker provides several specialized views accessible via the sidebar or keybo | **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions | | **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions | | **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference | -| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations | | **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more | | **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions | -| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks | +| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization | +| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance | +| **Memory** | `Y` | View and manage agent memory and conversation history | +| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks | +| **GitHub PRs** | `R` | View and manage GitHub pull requests | | **Running Agents** | - | View all active agents across projects with status and progress | ### Keyboard Navigation All shortcuts are customizable in Settings. Default shortcuts: -- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal) +- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs) - **UI:** `` ` `` (Toggle sidebar) -- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker) +- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker) - **Projects:** `Q`/`E` (Cycle previous/next project) +- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab) ## Architecture @@ -586,10 +588,16 @@ Stored in `{projectPath}/.automaker/`: │ ├── agent-output.md # AI agent output log │ └── images/ # Attached images ├── context/ # Context files for AI agents +├── worktrees/ # Git worktree metadata +├── validations/ # GitHub issue validation results +├── ideation/ # Brainstorming and analysis data +│ └── analysis.json # Project structure analysis +├── board/ # Board-related data +├── images/ # Project-level images ├── settings.json # Project-specific settings -├── spec.md # Project specification -├── analysis.json # Project structure analysis -└── feature-suggestions.json # AI-generated suggestions +├── app_spec.txt # Project specification (XML format) +├── active-branches.json # Active git branches tracking +└── execution-state.json # Auto-mode execution state ``` #### Global Data @@ -627,7 +635,6 @@ data/ - [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker - [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs -- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment - [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages ### Community diff --git a/apps/server/.env.example b/apps/server/.env.example index 68b28395..a73e3443 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -44,6 +44,11 @@ CORS_ORIGIN=http://localhost:3007 # OPTIONAL - Server # ============================================ +# Host to bind the server to (default: 0.0.0.0) +# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access) +# Use 127.0.0.1 or localhost to restrict to local connections only +HOST=0.0.0.0 + # Port to run the server on PORT=3008 @@ -63,6 +68,14 @@ TERMINAL_PASSWORD= ENABLE_REQUEST_LOGGING=false +# ============================================ +# OPTIONAL - UI Behavior +# ============================================ + +# Skip the sandbox warning dialog on startup (default: false) +# Set to "true" to disable the warning entirely (useful for dev/CI environments) +AUTOMAKER_SKIP_SANDBOX_WARNING=false + # ============================================ # OPTIONAL - Debugging # ============================================ diff --git a/apps/server/package.json b/apps/server/package.json index 3cc4fe18..e214eb02 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/server", - "version": "0.11.0", + "version": "0.12.0", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 609be945..d90c7a36 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -17,9 +17,19 @@ import dotenv from 'dotenv'; import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { initAllowedPaths } from '@automaker/platform'; -import { createLogger } from '@automaker/utils'; +import { createLogger, setLogLevel, LogLevel } from '@automaker/utils'; const logger = createLogger('Server'); + +/** + * Map server log level string to LogLevel enum + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; import { requireJsonContentType } from './middleware/require-json-content-type.js'; import { createAuthRoutes } from './routes/auth/index.js'; @@ -68,13 +78,37 @@ import { pipelineService } from './services/pipeline-service.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; import { getDevServerService } from './services/dev-server-service.js'; +import { eventHookService } from './services/event-hook-service.js'; +import { createNotificationsRoutes } from './routes/notifications/index.js'; +import { getNotificationService } from './services/notification-service.js'; +import { createEventHistoryRoutes } from './routes/event-history/index.js'; +import { getEventHistoryService } from './services/event-history-service.js'; // Load environment variables dotenv.config(); const PORT = parseInt(process.env.PORT || '3008', 10); +const HOST = process.env.HOST || '0.0.0.0'; +const HOSTNAME = process.env.HOSTNAME || 'localhost'; const DATA_DIR = process.env.DATA_DIR || './data'; -const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true +const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true + +// Runtime-configurable request logging flag (can be changed via settings) +let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT; + +/** + * Enable or disable HTTP request logging at runtime + */ +export function setRequestLoggingEnabled(enabled: boolean): void { + requestLoggingEnabled = enabled; +} + +/** + * Get current request logging state + */ +export function isRequestLoggingEnabled(): boolean { + return requestLoggingEnabled; +} // Check for required environment variables const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; @@ -103,22 +137,21 @@ initAllowedPaths(); const app = express(); // Middleware -// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var) -if (ENABLE_REQUEST_LOGGING) { - morgan.token('status-colored', (_req, res) => { - const status = res.statusCode; - if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors - if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors - if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects - return `\x1b[32m${status}\x1b[0m`; // Green for success - }); +// Custom colored logger showing only endpoint and status code (dynamically configurable) +morgan.token('status-colored', (_req, res) => { + const status = res.statusCode; + if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors + if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors + if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects + return `\x1b[32m${status}\x1b[0m`; // Green for success +}); - app.use( - morgan(':method :url :status-colored', { - skip: (req) => req.url === '/api/health', // Skip health check logs - }) - ); -} +app.use( + morgan(':method :url :status-colored', { + // Skip when request logging is disabled or for health check endpoints + skip: (req) => !requestLoggingEnabled || req.url === '/api/health', + }) +); // CORS configuration // When using credentials (cookies), origin cannot be '*' // We dynamically allow the requesting origin for local development @@ -181,8 +214,33 @@ const ideationService = new IdeationService(events, settingsService, featureLoad const devServerService = getDevServerService(); devServerService.setEventEmitter(events); +// Initialize Notification Service with event emitter for real-time updates +const notificationService = getNotificationService(); +notificationService.setEventEmitter(events); + +// Initialize Event History Service +const eventHistoryService = getEventHistoryService(); + +// Initialize Event Hook Service for custom event triggers (with history storage) +eventHookService.initialize(events, settingsService, eventHistoryService); + // Initialize services (async () => { + // Apply logging settings from saved settings + try { + const settings = await settingsService.getGlobalSettings(); + if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) { + setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]); + logger.info(`Server log level set to: ${settings.serverLogLevel}`); + } + // Apply request logging setting (default true if not set) + const enableRequestLog = settings.enableRequestLogging ?? true; + setRequestLoggingEnabled(enableRequestLog); + logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + } catch (err) { + logger.warn('Failed to load logging settings, using defaults'); + } + await agentService.initialize(); logger.info('Agent service initialized'); @@ -219,7 +277,7 @@ app.get('/api/health/detailed', createDetailedHandler()); app.use('/api/fs', createFsRoutes(events)); app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/sessions', createSessionsRoutes(agentService)); -app.use('/api/features', createFeaturesRoutes(featureLoader)); +app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); @@ -240,6 +298,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); +app.use('/api/notifications', createNotificationsRoutes(notificationService)); +app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); // Create HTTP server const server = createServer(app); @@ -551,8 +611,8 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage }); // Start server with error handling for port conflicts -const startServer = (port: number) => { - server.listen(port, () => { +const startServer = (port: number, host: string) => { + server.listen(port, host, () => { const terminalStatus = isTerminalEnabled() ? isTerminalPasswordRequired() ? 'enabled (password protected)' @@ -563,10 +623,11 @@ const startServer = (port: number) => { ╔═══════════════════════════════════════════════════════╗ ║ Automaker Backend Server ║ ╠═══════════════════════════════════════════════════════╣ -║ HTTP API: http://localhost:${portStr} ║ -║ WebSocket: ws://localhost:${portStr}/api/events ║ -║ Terminal: ws://localhost:${portStr}/api/terminal/ws ║ -║ Health: http://localhost:${portStr}/api/health ║ +║ Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}║ +║ HTTP API: http://${HOSTNAME}:${portStr} ║ +║ WebSocket: ws://${HOSTNAME}:${portStr}/api/events ║ +║ Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws ║ +║ Health: http://${HOSTNAME}:${portStr}/api/health ║ ║ Terminal: ${terminalStatus.padEnd(37)}║ ╚═══════════════════════════════════════════════════════╝ `); @@ -600,7 +661,7 @@ const startServer = (port: number) => { }); }; -startServer(PORT); +startServer(PORT, HOST); // Global error handlers to prevent crashes from uncaught errors process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { diff --git a/apps/server/src/lib/app-spec-format.ts b/apps/server/src/lib/app-spec-format.ts index 2894bbc4..f8393cf1 100644 --- a/apps/server/src/lib/app-spec-format.ts +++ b/apps/server/src/lib/app-spec-format.ts @@ -11,8 +11,12 @@ export { specOutputSchema } from '@automaker/types'; /** * Escape special XML characters + * Handles undefined/null values by converting them to empty strings */ -function escapeXml(str: string): string { +export function escapeXml(str: string | undefined | null): string { + if (str == null) { + return ''; + } return str .replace(/&/g, '&') .replace(/; backlogPlan: ReturnType; enhancement: ReturnType; + commitMessage: ReturnType; + titleGeneration: ReturnType; + issueValidation: ReturnType; + ideation: ReturnType; + appSpec: ReturnType; + contextDescription: ReturnType; + suggestions: ReturnType; + taskExecution: ReturnType; }> { let customization: PromptCustomization = {}; @@ -239,6 +255,14 @@ export async function getPromptCustomization( agent: mergeAgentPrompts(customization.agent), backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan), enhancement: mergeEnhancementPrompts(customization.enhancement), + commitMessage: mergeCommitMessagePrompts(customization.commitMessage), + titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration), + issueValidation: mergeIssueValidationPrompts(customization.issueValidation), + ideation: mergeIdeationPrompts(customization.ideation), + appSpec: mergeAppSpecPrompts(customization.appSpec), + contextDescription: mergeContextDescriptionPrompts(customization.contextDescription), + suggestions: mergeSuggestionsPrompts(customization.suggestions), + taskExecution: mergeTaskExecutionPrompts(customization.taskExecution), }; } diff --git a/apps/server/src/lib/xml-extractor.ts b/apps/server/src/lib/xml-extractor.ts new file mode 100644 index 00000000..72963b82 --- /dev/null +++ b/apps/server/src/lib/xml-extractor.ts @@ -0,0 +1,611 @@ +/** + * XML Extraction Utilities + * + * Robust XML parsing utilities for extracting and updating sections + * from app_spec.txt XML content. Uses regex-based parsing which is + * sufficient for our controlled XML structure. + * + * Note: If more complex XML parsing is needed in the future, consider + * using a library like 'fast-xml-parser' or 'xml2js'. + */ + +import { createLogger } from '@automaker/utils'; +import type { SpecOutput } from '@automaker/types'; + +const logger = createLogger('XmlExtractor'); + +/** + * Represents an implemented feature extracted from XML + */ +export interface ImplementedFeature { + name: string; + description: string; + file_locations?: string[]; +} + +/** + * Logger interface for optional custom logging + */ +export interface XmlExtractorLogger { + debug: (message: string, ...args: unknown[]) => void; + warn?: (message: string, ...args: unknown[]) => void; +} + +/** + * Options for XML extraction operations + */ +export interface ExtractXmlOptions { + /** Custom logger (defaults to internal logger) */ + logger?: XmlExtractorLogger; +} + +/** + * Escape special XML characters + * Handles undefined/null values by converting them to empty strings + */ +export function escapeXml(str: string | undefined | null): string { + if (str == null) { + return ''; + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Unescape XML entities back to regular characters + */ +export function unescapeXml(str: string): string { + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +/** + * Extract the content of a specific XML section + * + * @param xmlContent - The full XML content + * @param tagName - The tag name to extract (e.g., 'implemented_features') + * @param options - Optional extraction options + * @returns The content between the tags, or null if not found + */ +export function extractXmlSection( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string | null { + const log = options.logger || logger; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i'); + const match = xmlContent.match(regex); + + if (match) { + log.debug(`Extracted <${tagName}> section`); + return match[1]; + } + + log.debug(`Section <${tagName}> not found`); + return null; +} + +/** + * Extract all values from repeated XML elements + * + * @param xmlContent - The XML content to search + * @param tagName - The tag name to extract values from + * @param options - Optional extraction options + * @returns Array of extracted values (unescaped) + */ +export function extractXmlElements( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + const values: string[] = []; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g'); + const matches = xmlContent.matchAll(regex); + + for (const match of matches) { + values.push(unescapeXml(match[1].trim())); + } + + log.debug(`Extracted ${values.length} <${tagName}> elements`); + return values; +} + +/** + * Extract implemented features from app_spec.txt XML content + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of implemented features with name, description, and optional file_locations + */ +export function extractImplementedFeatures( + specContent: string, + options: ExtractXmlOptions = {} +): ImplementedFeature[] { + const log = options.logger || logger; + const features: ImplementedFeature[] = []; + + // Match ... section + const implementedSection = extractXmlSection(specContent, 'implemented_features', options); + + if (!implementedSection) { + log.debug('No implemented_features section found'); + return features; + } + + // Extract individual feature blocks + const featureRegex = /([\s\S]*?)<\/feature>/g; + const featureMatches = implementedSection.matchAll(featureRegex); + + for (const featureMatch of featureMatches) { + const featureContent = featureMatch[1]; + + // Extract name + const nameMatch = featureContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + // Extract description + const descMatch = featureContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : ''; + + // Extract file_locations if present + const locationsSection = extractXmlSection(featureContent, 'file_locations', options); + const file_locations = locationsSection + ? extractXmlElements(locationsSection, 'location', options) + : undefined; + + if (name) { + features.push({ + name, + description, + ...(file_locations && file_locations.length > 0 ? { file_locations } : {}), + }); + } + } + + log.debug(`Extracted ${features.length} implemented features`); + return features; +} + +/** + * Extract only the feature names from implemented_features section + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of feature names + */ +export function extractImplementedFeatureNames( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const features = extractImplementedFeatures(specContent, options); + return features.map((f) => f.name); +} + +/** + * Generate XML for a single implemented feature + * + * @param feature - The feature to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the feature + */ +export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string { + const i2 = indent.repeat(2); + const i3 = indent.repeat(3); + const i4 = indent.repeat(4); + + let xml = `${i2} +${i3}${escapeXml(feature.name)} +${i3}${escapeXml(feature.description)}`; + + if (feature.file_locations && feature.file_locations.length > 0) { + xml += ` +${i3} +${feature.file_locations.map((loc) => `${i4}${escapeXml(loc)}`).join('\n')} +${i3}`; + } + + xml += ` +${i2}`; + + return xml; +} + +/** + * Generate XML for an array of implemented features + * + * @param features - Array of features to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the implemented_features section content + */ +export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string { + return features.map((f) => featureToXml(f, indent)).join('\n'); +} + +/** + * Update the implemented_features section in XML content + * + * @param specContent - The full XML content + * @param newFeatures - The new features to set + * @param options - Optional extraction options + * @returns Updated XML content with the new implemented_features section + */ +export function updateImplementedFeaturesSection( + specContent: string, + newFeatures: ImplementedFeature[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + + // Generate new section content + const newSectionContent = featuresToXml(newFeatures, indent); + + // Build the new section + const newSection = ` +${newSectionContent} +${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/implemented_features>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing implemented_features section'); + return specContent.replace(sectionRegex, newSection); + } + + // If section doesn't exist, try to insert after core_capabilities + const coreCapabilitiesEnd = ''; + const insertIndex = specContent.indexOf(coreCapabilitiesEnd); + + if (insertIndex !== -1) { + const insertPosition = insertIndex + coreCapabilitiesEnd.length; + log.debug('Inserting implemented_features after core_capabilities'); + return ( + specContent.slice(0, insertPosition) + + '\n\n' + + indent + + newSection + + specContent.slice(insertPosition) + ); + } + + // As a fallback, insert before + const projectSpecEnd = ''; + const fallbackIndex = specContent.indexOf(projectSpecEnd); + + if (fallbackIndex !== -1) { + log.debug('Inserting implemented_features before '); + return ( + specContent.slice(0, fallbackIndex) + + indent + + newSection + + '\n' + + specContent.slice(fallbackIndex) + ); + } + + log.warn?.('Could not find appropriate insertion point for implemented_features'); + log.debug('Could not find appropriate insertion point for implemented_features'); + return specContent; +} + +/** + * Add a new feature to the implemented_features section + * + * @param specContent - The full XML content + * @param newFeature - The feature to add + * @param options - Optional extraction options + * @returns Updated XML content with the new feature added + */ +export function addImplementedFeature( + specContent: string, + newFeature: ImplementedFeature, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Check for duplicates by name + const isDuplicate = existingFeatures.some( + (f) => f.name.toLowerCase() === newFeature.name.toLowerCase() + ); + + if (isDuplicate) { + log.debug(`Feature "${newFeature.name}" already exists, skipping`); + return specContent; + } + + // Add the new feature + const updatedFeatures = [...existingFeatures, newFeature]; + + log.debug(`Adding feature "${newFeature.name}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Remove a feature from the implemented_features section by name + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to remove + * @param options - Optional extraction options + * @returns Updated XML content with the feature removed + */ +export function removeImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Filter out the feature to remove + const updatedFeatures = existingFeatures.filter( + (f) => f.name.toLowerCase() !== featureName.toLowerCase() + ); + + if (updatedFeatures.length === existingFeatures.length) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Removing feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Update an existing feature in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to update + * @param updates - Partial updates to apply to the feature + * @param options - Optional extraction options + * @returns Updated XML content with the feature modified + */ +export function updateImplementedFeature( + specContent: string, + featureName: string, + updates: Partial, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Find and update the feature + let found = false; + const updatedFeatures = existingFeatures.map((f) => { + if (f.name.toLowerCase() === featureName.toLowerCase()) { + found = true; + return { + ...f, + ...updates, + // Preserve the original name if not explicitly updated + name: updates.name ?? f.name, + }; + } + return f; + }); + + if (!found) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Updating feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Check if a feature exists in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to check + * @param options - Optional extraction options + * @returns True if the feature exists + */ +export function hasImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): boolean { + const features = extractImplementedFeatures(specContent, options); + return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase()); +} + +/** + * Convert extracted features to SpecOutput.implemented_features format + * + * @param features - Array of extracted features + * @returns Features in SpecOutput format + */ +export function toSpecOutputFeatures( + features: ImplementedFeature[] +): SpecOutput['implemented_features'] { + return features.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} + +/** + * Convert SpecOutput.implemented_features to ImplementedFeature format + * + * @param specFeatures - Features from SpecOutput + * @returns Features in ImplementedFeature format + */ +export function fromSpecOutputFeatures( + specFeatures: SpecOutput['implemented_features'] +): ImplementedFeature[] { + return specFeatures.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} + +/** + * Represents a roadmap phase extracted from XML + */ +export interface RoadmapPhase { + name: string; + status: string; + description?: string; +} + +/** + * Extract the technology stack from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of technology names + */ +export function extractTechnologyStack( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + + const techSection = extractXmlSection(specContent, 'technology_stack', options); + if (!techSection) { + log.debug('No technology_stack section found'); + return []; + } + + const technologies = extractXmlElements(techSection, 'technology', options); + log.debug(`Extracted ${technologies.length} technologies`); + return technologies; +} + +/** + * Update the technology_stack section in XML content + * + * @param specContent - The full XML content + * @param technologies - The new technology list + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateTechnologyStack( + specContent: string, + technologies: string[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + const i2 = indent.repeat(2); + + // Generate new section content + const techXml = technologies + .map((t) => `${i2}${escapeXml(t)}`) + .join('\n'); + const newSection = `\n${techXml}\n${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/technology_stack>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing technology_stack section'); + return specContent.replace(sectionRegex, newSection); + } + + log.debug('No technology_stack section found to update'); + return specContent; +} + +/** + * Extract roadmap phases from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of roadmap phases + */ +export function extractRoadmapPhases( + specContent: string, + options: ExtractXmlOptions = {} +): RoadmapPhase[] { + const log = options.logger || logger; + const phases: RoadmapPhase[] = []; + + const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options); + if (!roadmapSection) { + log.debug('No implementation_roadmap section found'); + return phases; + } + + // Extract individual phase blocks + const phaseRegex = /([\s\S]*?)<\/phase>/g; + const phaseMatches = roadmapSection.matchAll(phaseRegex); + + for (const phaseMatch of phaseMatches) { + const phaseContent = phaseMatch[1]; + + const nameMatch = phaseContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + const statusMatch = phaseContent.match(/([\s\S]*?)<\/status>/); + const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending'; + + const descMatch = phaseContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined; + + if (name) { + phases.push({ name, status, description }); + } + } + + log.debug(`Extracted ${phases.length} roadmap phases`); + return phases; +} + +/** + * Update a roadmap phase status in XML content + * + * @param specContent - The full XML content + * @param phaseName - The name of the phase to update + * @param newStatus - The new status value + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateRoadmapPhaseStatus( + specContent: string, + phaseName: string, + newStatus: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Find the phase and update its status + // Match the phase block containing the specific name + const phaseRegex = new RegExp( + `(\\s*\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*)[\\s\\S]*?(<\\/status>)`, + 'i' + ); + + if (phaseRegex.test(specContent)) { + log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`); + return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`); + } + + log.debug(`Phase "${phaseName}" not found`); + return specContent; +} diff --git a/apps/server/src/providers/cli-provider.ts b/apps/server/src/providers/cli-provider.ts index 8683f841..ea636cb8 100644 --- a/apps/server/src/providers/cli-provider.ts +++ b/apps/server/src/providers/cli-provider.ts @@ -35,6 +35,7 @@ import { type SubprocessOptions, type WslCliResult, } from '@automaker/platform'; +import { calculateReasoningTimeout } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; import { execSync } from 'child_process'; import * as fs from 'fs'; @@ -107,6 +108,15 @@ export interface CliDetectionResult { // Create logger for CLI operations const cliLogger = createLogger('CliProvider'); +/** + * Base timeout for CLI operations in milliseconds. + * CLI tools have longer startup and processing times compared to direct API calls, + * so we use a higher base timeout (120s) than the default provider timeout (30s). + * This is multiplied by reasoning effort multipliers when applicable. + * @see calculateReasoningTimeout from @automaker/types + */ +const CLI_BASE_TIMEOUT_MS = 120000; + /** * Abstract base class for CLI-based providers * @@ -450,6 +460,10 @@ export abstract class CliProvider extends BaseProvider { } } + // Calculate dynamic timeout based on reasoning effort. + // This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout. + const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS); + // WSL strategy if (this.useWsl && this.wslCliPath) { const wslCwd = windowsToWslPath(cwd); @@ -473,7 +487,7 @@ export abstract class CliProvider extends BaseProvider { cwd, // Windows cwd for spawn env: filteredEnv, abortController: options.abortController, - timeout: 120000, // CLI operations may take longer + timeout, }; } @@ -488,7 +502,7 @@ export abstract class CliProvider extends BaseProvider { cwd, env: filteredEnv, abortController: options.abortController, - timeout: 120000, + timeout, }; } @@ -501,7 +515,7 @@ export abstract class CliProvider extends BaseProvider { cwd, env: filteredEnv, abortController: options.abortController, - timeout: 120000, + timeout, }; } diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index e0f38ee9..18838cb8 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -33,6 +33,8 @@ import { CODEX_MODEL_MAP, supportsReasoningEffort, validateBareModelId, + calculateReasoningTimeout, + DEFAULT_TIMEOUT_MS, type CodexApprovalPolicy, type CodexSandboxMode, type CodexAuthStatus, @@ -91,7 +93,14 @@ const CODEX_ITEM_TYPES = { const SYSTEM_PROMPT_LABEL = 'System instructions'; const HISTORY_HEADER = 'Current request:\n'; const TEXT_ENCODING = 'utf-8'; -const DEFAULT_TIMEOUT_MS = 30000; +/** + * Default timeout for Codex CLI operations in milliseconds. + * This is the "no output" timeout - if the CLI doesn't produce any JSONL output + * for this duration, the process is killed. For reasoning models with high + * reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout(). + * @see calculateReasoningTimeout from @automaker/types + */ +const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS; const CONTEXT_WINDOW_256K = 256000; const MAX_OUTPUT_32K = 32000; const MAX_OUTPUT_16K = 16000; @@ -814,13 +823,19 @@ export class CodexProvider extends BaseProvider { envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey; } + // Calculate dynamic timeout based on reasoning effort. + // Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time + // for the model to generate reasoning tokens before producing output. + // This fixes GitHub issue #530 where features would get stuck with reasoning models. + const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS); + const stream = spawnJSONLProcess({ command: commandPath, args, cwd: options.cwd, env: envOverrides, abortController: options.abortController, - timeout: DEFAULT_TIMEOUT_MS, + timeout, stdinData: promptText, // Pass prompt via stdin }); diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index 7ef1aabe..1a48fc6a 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('SpecRegeneration'); +// Types for running generation +export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync'; + +interface RunningGeneration { + isRunning: boolean; + type: GenerationType; + startedAt: string; +} + // Shared state for tracking generation status - scoped by project path -const runningProjects = new Map(); +const runningProjects = new Map(); const abortControllers = new Map(); /** @@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): { isRunning: boolean; currentAbortController: AbortController | null; projectPath?: string; + type?: GenerationType; + startedAt?: string; } { if (projectPath) { + const generation = runningProjects.get(projectPath); return { - isRunning: runningProjects.get(projectPath) || false, + isRunning: generation?.isRunning || false, currentAbortController: abortControllers.get(projectPath) || null, projectPath, + type: generation?.type, + startedAt: generation?.startedAt, }; } // Fallback: check if any project is running (for backward compatibility) - const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); + const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning); return { isRunning: isAnyRunning, currentAbortController: null }; } @@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null { export function setRunningState( projectPath: string, running: boolean, - controller: AbortController | null = null + controller: AbortController | null = null, + type: GenerationType = 'spec_regeneration' ): void { if (running) { - runningProjects.set(projectPath, true); + runningProjects.set(projectPath, { + isRunning: true, + type, + startedAt: new Date().toISOString(), + }); if (controller) { abortControllers.set(projectPath, controller); } @@ -59,6 +78,33 @@ export function setRunningState( } } +/** + * Get all running spec/feature generations for the running agents view + */ +export function getAllRunningGenerations(): Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; +}> { + const results: Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; + }> = []; + + for (const [projectPath, generation] of runningProjects.entries()) { + if (generation.isRunning) { + results.push({ + projectPath, + type: generation.type, + startedAt: generation.startedAt, + }); + } + } + + return results; +} + /** * Helper to log authentication status */ diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 55bf792b..24c3f398 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -14,7 +14,8 @@ import { streamingQuery } from '../../providers/simple-query-service.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; const logger = createLogger('SpecRegeneration'); @@ -53,38 +54,48 @@ export async function generateFeaturesFromSpec( return; } + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]'); + + // Load existing features to prevent duplicates + const featureLoader = new FeatureLoader(); + const existingFeatures = await featureLoader.getAll(projectPath); + + logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`); + + // Build existing features context for the prompt + let existingFeaturesContext = ''; + if (existingFeatures.length > 0) { + const featuresList = existingFeatures + .map( + (f) => + `- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}` + ) + .join('\n'); + existingFeaturesContext = ` + +## EXISTING FEATURES (DO NOT REGENERATE THESE) + +The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these: + +${featuresList} + +CRITICAL INSTRUCTIONS: +- DO NOT generate any features with the same or similar titles as the existing features listed above +- DO NOT generate features that cover the same functionality as existing features +- ONLY generate NEW features that are not yet in the system +- If a feature from the roadmap already exists, skip it entirely +- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')} +`; + } + const prompt = `Based on this project specification: ${spec} +${existingFeaturesContext} +${prompts.appSpec.generateFeaturesFromSpecPrompt} -Generate a prioritized list of implementable features. For each feature provide: - -1. **id**: A unique lowercase-hyphenated identifier -2. **category**: Functional category (e.g., "Core", "UI", "API", "Authentication", "Database") -3. **title**: Short descriptive title -4. **description**: What this feature does (2-3 sentences) -5. **priority**: 1 (high), 2 (medium), or 3 (low) -6. **complexity**: "simple", "moderate", or "complex" -7. **dependencies**: Array of feature IDs this depends on (can be empty) - -Format as JSON: -{ - "features": [ - { - "id": "feature-id", - "category": "Feature Category", - "title": "Feature Title", - "description": "What it does", - "priority": 1, - "complexity": "moderate", - "dependencies": [] - } - ] -} - -Generate ${featureCount} features that build on each other logically. - -IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`; +Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`; logger.info('========== PROMPT BEING SENT =========='); logger.info(`Prompt length: ${prompt.length} chars`); diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 4af42b48..4fa3d11a 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -7,12 +7,7 @@ import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; -import { - specOutputSchema, - specToXml, - getStructuredSpecPromptInstruction, - type SpecOutput, -} from '../../lib/app-spec-format.js'; +import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; @@ -21,7 +16,7 @@ import { streamingQuery } from '../../providers/simple-query-service.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; const logger = createLogger('SpecRegeneration'); @@ -43,6 +38,9 @@ export async function generateSpec( logger.info('analyzeProject:', analyzeProject); logger.info('maxFeatures:', maxFeatures); + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]'); + // Build the prompt based on whether we should analyze the project let analysisInstructions = ''; let techStackDefaults = ''; @@ -66,9 +64,7 @@ export async function generateSpec( Use these technologies as the foundation for the specification.`; } - const prompt = `You are helping to define a software project specification. - -IMPORTANT: Never ask for clarification or additional information. Use the information provided and make reasonable assumptions to create the best possible specification. If details are missing, infer them based on common patterns and best practices. + const prompt = `${prompts.appSpec.generateSpecSystemPrompt} Project Overview: ${projectOverview} @@ -77,7 +73,7 @@ ${techStackDefaults} ${analysisInstructions} -${getStructuredSpecPromptInstruction()}`; +${prompts.appSpec.structuredSpecInstructions}`; logger.info('========== PROMPT BEING SENT =========='); logger.info(`Prompt length: ${prompt.length} chars`); @@ -205,19 +201,33 @@ Your entire response should be valid JSON starting with { and ending with }. No xmlContent = responseText.substring(xmlStart, xmlEnd + ''.length); logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); } else { - // No valid XML structure found in the response text - // This happens when structured output was expected but not received, and the agent - // output conversational text instead of XML (e.g., "The project directory appears to be empty...") - // We should NOT save this conversational text as it's not a valid spec - logger.error('❌ Response does not contain valid XML structure'); - logger.error( - 'This typically happens when structured output failed and the agent produced conversational text instead of XML' - ); - throw new Error( - 'Failed to generate spec: No valid XML structure found in response. ' + - 'The response contained conversational text but no tags. ' + - 'Please try again.' - ); + // No XML found, try JSON extraction + logger.warn('⚠️ No XML tags found, attempting JSON extraction...'); + const extractedJson = extractJson(responseText, { logger }); + + if ( + extractedJson && + typeof extractedJson.project_name === 'string' && + typeof extractedJson.overview === 'string' && + Array.isArray(extractedJson.technology_stack) && + Array.isArray(extractedJson.core_capabilities) && + Array.isArray(extractedJson.implemented_features) + ) { + logger.info('✅ Successfully extracted JSON from response text'); + xmlContent = specToXml(extractedJson); + logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`); + } else { + // Neither XML nor valid JSON found + logger.error('❌ Response does not contain valid XML or JSON structure'); + logger.error( + 'This typically happens when structured output failed and the agent produced conversational text instead of structured output' + ); + throw new Error( + 'Failed to generate spec: No valid XML or JSON structure found in response. ' + + 'The response contained conversational text but no tags or valid JSON. ' + + 'Please try again.' + ); + } } } diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 342aecd7..79b8e44d 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js'; import { createCreateHandler } from './routes/create.js'; import { createGenerateHandler } from './routes/generate.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.js'; +import { createSyncHandler } from './routes/sync.js'; import { createStopHandler } from './routes/stop.js'; import { createStatusHandler } from './routes/status.js'; import type { SettingsService } from '../../services/settings-service.js'; @@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes( router.post('/create', createCreateHandler(events)); router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); + router.post('/sync', createSyncHandler(events, settingsService)); router.post('/stop', createStopHandler()); router.get('/status', createStatusHandler()); diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 78137a73..47846fcc 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -5,9 +5,10 @@ import path from 'path'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; -import { createLogger } from '@automaker/utils'; +import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils'; import { getFeaturesDir } from '@automaker/platform'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; +import { getNotificationService } from '../../services/notification-service.js'; const logger = createLogger('SpecRegeneration'); @@ -73,10 +74,10 @@ export async function parseAndCreateFeatures( updatedAt: new Date().toISOString(), }; - await secureFs.writeFile( - path.join(featureDir, 'feature.json'), - JSON.stringify(featureData, null, 2) - ); + // Use atomic write with backup support for crash protection + await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, { + backupCount: DEFAULT_BACKUP_COUNT, + }); createdFeatures.push({ id: feature.id, title: feature.title }); } @@ -88,6 +89,15 @@ export async function parseAndCreateFeatures( message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, projectPath: projectPath, }); + + // Create notification for spec generation completion + const notificationService = getNotificationService(); + await notificationService.createNotification({ + type: 'spec_regeneration_complete', + title: 'Spec Generation Complete', + message: `Created ${createdFeatures.length} features from the project specification.`, + projectPath: projectPath, + }); } catch (error) { logger.error('❌ parseAndCreateFeatures() failed:'); logger.error('Error:', error); diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index dc627964..670652ea 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler( logAuthStatus('Before starting feature generation'); const abortController = new AbortController(); - setRunningState(projectPath, true, abortController); + setRunningState(projectPath, true, abortController, 'feature_generation'); logger.info('Starting background feature generation task...'); generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) diff --git a/apps/server/src/routes/app-spec/routes/sync.ts b/apps/server/src/routes/app-spec/routes/sync.ts new file mode 100644 index 00000000..c6c34e68 --- /dev/null +++ b/apps/server/src/routes/app-spec/routes/sync.ts @@ -0,0 +1,76 @@ +/** + * POST /sync endpoint - Sync spec with codebase and features + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { syncSpec } from '../sync-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +const logger = createLogger('SpecSync'); + +export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /sync endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + logger.debug('projectPath:', projectPath); + + if (!projectPath) { + logger.error('Missing projectPath parameter'); + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation/sync already running for project:', projectPath); + res.json({ success: false, error: 'Operation already running for this project' }); + return; + } + + logAuthStatus('Before starting spec sync'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController, 'sync'); + logger.info('Starting background spec sync task...'); + + syncSpec(projectPath, events, abortController, settingsService) + .then((result) => { + logger.info('Spec sync completed successfully'); + logger.info('Result:', JSON.stringify(result, null, 2)); + }) + .catch((error) => { + logError(error, 'Spec sync failed with error'); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: getErrorMessage(error), + projectPath, + }); + }) + .finally(() => { + logger.info('Spec sync task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (sync running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Sync route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts new file mode 100644 index 00000000..98352855 --- /dev/null +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -0,0 +1,307 @@ +/** + * Sync spec with current codebase and feature state + * + * Updates the spec file based on: + * - Completed Automaker features + * - Code analysis for tech stack and implementations + * - Roadmap phase status updates + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { streamingQuery } from '../../providers/simple-query-service.js'; +import { getAppSpecPath } from '@automaker/platform'; +import type { SettingsService } from '../../services/settings-service.js'; +import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import { + extractImplementedFeatures, + extractTechnologyStack, + extractRoadmapPhases, + updateImplementedFeaturesSection, + updateTechnologyStack, + updateRoadmapPhaseStatus, + type ImplementedFeature, + type RoadmapPhase, +} from '../../lib/xml-extractor.js'; +import { getNotificationService } from '../../services/notification-service.js'; + +const logger = createLogger('SpecSync'); + +/** + * Result of a sync operation + */ +export interface SyncResult { + techStackUpdates: { + added: string[]; + removed: string[]; + }; + implementedFeaturesUpdates: { + addedFromFeatures: string[]; + removed: string[]; + }; + roadmapUpdates: Array<{ phaseName: string; newStatus: string }>; + summary: string; +} + +/** + * Sync the spec with current codebase and feature state + */ +export async function syncSpec( + projectPath: string, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService +): Promise { + logger.info('========== syncSpec() started =========='); + logger.info('projectPath:', projectPath); + + const result: SyncResult = { + techStackUpdates: { added: [], removed: [] }, + implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] }, + roadmapUpdates: [], + summary: '', + }; + + // Read existing spec + const specPath = getAppSpecPath(projectPath); + let specContent: string; + + try { + specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + logger.info(`Spec loaded successfully (${specContent.length} chars)`); + } catch (readError) { + logger.error('Failed to read spec file:', readError); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: 'No project spec found. Create or regenerate spec first.', + projectPath, + }); + throw new Error('No project spec found'); + } + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: '[Phase: sync] Starting spec sync...\n', + projectPath, + }); + + // Extract current state from spec + const currentImplementedFeatures = extractImplementedFeatures(specContent); + const currentTechStack = extractTechnologyStack(specContent); + const currentRoadmapPhases = extractRoadmapPhases(specContent); + + logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`); + logger.info(`Current spec has ${currentTechStack.length} technologies`); + logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`); + + // Load completed Automaker features + const featureLoader = new FeatureLoader(); + const allFeatures = await featureLoader.getAll(projectPath); + const completedFeatures = allFeatures.filter( + (f) => f.status === 'completed' || f.status === 'verified' + ); + + logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: `Found ${completedFeatures.length} completed features to sync...\n`, + projectPath, + }); + + // Build new implemented features list from completed Automaker features + const newImplementedFeatures: ImplementedFeature[] = []; + const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase())); + + for (const feature of completedFeatures) { + const name = feature.title || `Feature: ${feature.id}`; + if (!existingNames.has(name.toLowerCase())) { + newImplementedFeatures.push({ + name, + description: feature.description || '', + }); + result.implementedFeaturesUpdates.addedFromFeatures.push(name); + } + } + + // Merge: keep existing + add new from completed features + const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures]; + + // Update spec with merged features + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + specContent = updateImplementedFeaturesSection(specContent, mergedFeatures); + logger.info( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec` + ); + } + + // Analyze codebase for tech stack updates using AI + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Analyzing codebase for technology updates...\n', + projectPath, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[SpecSync]' + ); + + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + // Use AI to analyze tech stack + const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. + +Current known technologies: ${currentTechStack.join(', ')} + +Look at package.json, config files, and source code to identify: +- Frameworks (React, Vue, Express, etc.) +- Languages (TypeScript, JavaScript, Python, etc.) +- Build tools (Vite, Webpack, etc.) +- Databases (PostgreSQL, MongoDB, etc.) +- Key libraries and tools + +Return ONLY this JSON format, no other text: +{ + "technologies": ["Technology 1", "Technology 2", ...] +}`; + + try { + const techResult = await streamingQuery({ + prompt: techAnalysisPrompt, + model, + cwd: projectPath, + maxTurns: 10, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + onText: (text) => { + logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); + }, + }); + + // Parse tech stack from response + const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (Array.isArray(parsed.technologies)) { + const newTechStack = parsed.technologies as string[]; + + // Calculate differences + const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase())); + const newSet = new Set(newTechStack.map((t) => t.toLowerCase())); + + for (const tech of newTechStack) { + if (!currentSet.has(tech.toLowerCase())) { + result.techStackUpdates.added.push(tech); + } + } + + for (const tech of currentTechStack) { + if (!newSet.has(tech.toLowerCase())) { + result.techStackUpdates.removed.push(tech); + } + } + + // Update spec with new tech stack if there are changes + if ( + result.techStackUpdates.added.length > 0 || + result.techStackUpdates.removed.length > 0 + ) { + specContent = updateTechnologyStack(specContent, newTechStack); + logger.info( + `Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}` + ); + } + } + } + } catch (error) { + logger.warn('Failed to analyze tech stack:', error); + // Continue with other sync operations + } + + // Update roadmap phase statuses based on completed features + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Checking roadmap phase statuses...\n', + projectPath, + }); + + // For each phase, check if all its features are completed + // This is a heuristic - we check if the phase name appears in any feature titles/descriptions + for (const phase of currentRoadmapPhases) { + if (phase.status === 'completed') continue; // Already completed + + // Check if this phase should be marked as completed + // A phase is considered complete if we have completed features that mention it + const phaseNameLower = phase.name.toLowerCase(); + const relatedCompletedFeatures = completedFeatures.filter( + (f) => + f.title?.toLowerCase().includes(phaseNameLower) || + f.description?.toLowerCase().includes(phaseNameLower) || + f.category?.toLowerCase().includes(phaseNameLower) + ); + + // If we have related completed features and the phase is still pending/in_progress, + // update it to in_progress or completed based on feature count + if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') { + const newStatus = 'in_progress'; + specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus); + result.roadmapUpdates.push({ phaseName: phase.name, newStatus }); + logger.info(`Updated phase "${phase.name}" to ${newStatus}`); + } + } + + // Save updated spec + await secureFs.writeFile(specPath, specContent, 'utf-8'); + logger.info('Spec saved successfully'); + + // Build summary + const summaryParts: string[] = []; + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + summaryParts.push( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features` + ); + } + if (result.techStackUpdates.added.length > 0) { + summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`); + } + if (result.techStackUpdates.removed.length > 0) { + summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`); + } + if (result.roadmapUpdates.length > 0) { + summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`); + } + + result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date'; + + // Create notification + const notificationService = getNotificationService(); + await notificationService.createNotification({ + type: 'spec_regeneration_complete', + title: 'Spec Sync Complete', + message: result.summary, + projectPath, + }); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: `Spec sync complete! ${result.summary}`, + projectPath, + }); + + logger.info('========== syncSpec() completed =========='); + logger.info('Summary:', result.summary); + + return result; +} diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index 74fb44c8..1fab1e2a 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -3,12 +3,31 @@ */ import { createLogger } from '@automaker/utils'; +import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import type { BacklogPlanResult } from '@automaker/types'; const logger = createLogger('BacklogPlan'); // State for tracking running generation let isRunning = false; let currentAbortController: AbortController | null = null; +let runningDetails: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null = null; + +const BACKLOG_PLAN_FILENAME = 'backlog-plan.json'; + +export interface StoredBacklogPlan { + savedAt: string; + prompt: string; + model?: string; + result: BacklogPlanResult; +} export function getBacklogPlanStatus(): { isRunning: boolean } { return { isRunning }; @@ -16,11 +35,67 @@ export function getBacklogPlanStatus(): { isRunning: boolean } { export function setRunningState(running: boolean, abortController?: AbortController | null): void { isRunning = running; + if (!running) { + runningDetails = null; + } if (abortController !== undefined) { currentAbortController = abortController; } } +export function setRunningDetails( + details: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; + } | null +): void { + runningDetails = details; +} + +export function getRunningDetails(): { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null { + return runningDetails; +} + +function getBacklogPlanPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME); +} + +export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise { + await ensureAutomakerDir(projectPath); + const filePath = getBacklogPlanPath(projectPath); + await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8'); +} + +export async function loadBacklogPlan(projectPath: string): Promise { + try { + const filePath = getBacklogPlanPath(projectPath); + const raw = await secureFs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw as string) as StoredBacklogPlan; + if (!Array.isArray(parsed?.result?.changes)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export async function clearBacklogPlan(projectPath: string): Promise { + try { + const filePath = getBacklogPlanPath(projectPath); + await secureFs.unlink(filePath); + } catch { + // ignore missing file + } +} + export function getAbortController(): AbortController | null { return currentAbortController; } diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index d8235e50..e96ce8ea 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -17,7 +17,13 @@ import { resolvePhaseModel } from '@automaker/model-resolver'; import { FeatureLoader } from '../../services/feature-loader.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; -import { logger, setRunningState, getErrorMessage } from './common.js'; +import { + logger, + setRunningState, + setRunningDetails, + getErrorMessage, + saveBacklogPlan, +} from './common.js'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; @@ -200,6 +206,13 @@ ${userPrompt}`; // Parse the response const result = parsePlanResponse(responseText); + await saveBacklogPlan(projectPath, { + savedAt: new Date().toISOString(), + prompt, + model: effectiveModel, + result, + }); + events.emit('backlog-plan:event', { type: 'backlog_plan_complete', result, @@ -218,5 +231,6 @@ ${userPrompt}`; throw error; } finally { setRunningState(false, null); + setRunningDetails(null); } } diff --git a/apps/server/src/routes/backlog-plan/index.ts b/apps/server/src/routes/backlog-plan/index.ts index 393296df..4ab9e71d 100644 --- a/apps/server/src/routes/backlog-plan/index.ts +++ b/apps/server/src/routes/backlog-plan/index.ts @@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js'; import { createStopHandler } from './routes/stop.js'; import { createStatusHandler } from './routes/status.js'; import { createApplyHandler } from './routes/apply.js'; +import { createClearHandler } from './routes/clear.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createBacklogPlanRoutes( @@ -23,8 +24,9 @@ export function createBacklogPlanRoutes( createGenerateHandler(events, settingsService) ); router.post('/stop', createStopHandler()); - router.get('/status', createStatusHandler()); + router.get('/status', validatePathParams('projectPath'), createStatusHandler()); router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); + router.post('/clear', validatePathParams('projectPath'), createClearHandler()); return router; } diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index b6c257a0..9e0ae999 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types'; import { FeatureLoader } from '../../../services/feature-loader.js'; -import { getErrorMessage, logError, logger } from '../common.js'; +import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; const featureLoader = new FeatureLoader(); @@ -147,6 +147,17 @@ export function createApplyHandler() { } } + // Clear the plan before responding + try { + await clearBacklogPlan(projectPath); + } catch (error) { + logger.warn( + `[BacklogPlan] Failed to clear backlog plan after apply:`, + getErrorMessage(error) + ); + // Don't throw - operation succeeded, just cleanup failed + } + res.json({ success: true, appliedChanges, diff --git a/apps/server/src/routes/backlog-plan/routes/clear.ts b/apps/server/src/routes/backlog-plan/routes/clear.ts new file mode 100644 index 00000000..855dc507 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/clear.ts @@ -0,0 +1,25 @@ +/** + * POST /clear endpoint - Clear saved backlog plan + */ + +import type { Request, Response } from 'express'; +import { clearBacklogPlan, getErrorMessage, logError } from '../common.js'; + +export function createClearHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + await clearBacklogPlan(projectPath); + res.json({ success: true }); + } catch (error) { + logError(error, 'Clear backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index b596576f..0e9218e6 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -4,7 +4,13 @@ import type { Request, Response } from 'express'; import type { EventEmitter } from '../../../lib/events.js'; -import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js'; +import { + getBacklogPlanStatus, + setRunningState, + setRunningDetails, + getErrorMessage, + logError, +} from '../common.js'; import { generateBacklogPlan } from '../generate-plan.js'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -37,6 +43,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se } setRunningState(true); + setRunningDetails({ + projectPath, + prompt, + model, + startedAt: new Date().toISOString(), + }); const abortController = new AbortController(); setRunningState(true, abortController); @@ -51,6 +63,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se }) .finally(() => { setRunningState(false, null); + setRunningDetails(null); }); res.json({ success: true }); diff --git a/apps/server/src/routes/backlog-plan/routes/status.ts b/apps/server/src/routes/backlog-plan/routes/status.ts index 3b1684d3..5f20f1e2 100644 --- a/apps/server/src/routes/backlog-plan/routes/status.ts +++ b/apps/server/src/routes/backlog-plan/routes/status.ts @@ -3,13 +3,15 @@ */ import type { Request, Response } from 'express'; -import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js'; +import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js'; export function createStatusHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { const status = getBacklogPlanStatus(); - res.json({ success: true, ...status }); + const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : ''; + const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null; + res.json({ success: true, ...status, savedPlan }); } catch (error) { logError(error, 'Get backlog plan status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/backlog-plan/routes/stop.ts b/apps/server/src/routes/backlog-plan/routes/stop.ts index aa275c67..d969f1b1 100644 --- a/apps/server/src/routes/backlog-plan/routes/stop.ts +++ b/apps/server/src/routes/backlog-plan/routes/stop.ts @@ -3,7 +3,13 @@ */ import type { Request, Response } from 'express'; -import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js'; +import { + getAbortController, + setRunningState, + setRunningDetails, + getErrorMessage, + logError, +} from '../common.js'; export function createStopHandler() { return async (_req: Request, res: Response): Promise => { @@ -12,6 +18,7 @@ export function createStopHandler() { if (abortController) { abortController.abort(); setRunningState(false, null); + setRunningDetails(null); } res.json({ success: true }); } catch (error) { diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index ca8c2b4a..5b1fc6ca 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -19,7 +19,10 @@ import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, +} from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -130,11 +133,12 @@ export function createDescribeFileHandler( // Get the filename for context const fileName = path.basename(resolvedPath); + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[DescribeFile]'); + // Build prompt with file content passed as structured data // The file content is included directly, not via tool invocation - const prompt = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). - -Respond with ONLY the description text, no additional formatting, preamble, or explanation. + const prompt = `${prompts.contextDescription.describeFilePrompt} File: ${fileName}${truncated ? ' (truncated)' : ''} diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index e5e50963..70f9f7dc 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -19,7 +19,10 @@ import { simpleQuery } from '../../../providers/simple-query-service.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, +} from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeImage'); @@ -278,12 +281,11 @@ export function createDescribeImageHandler( logger.info(`[${requestId}] Using model: ${model}`); - // Build the instruction text - const instructionText = - `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + - `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + - `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + - `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); + + // Build the instruction text from centralized prompts + const instructionText = prompts.contextDescription.describeImagePrompt; // Build prompt based on provider capability // Some providers (like Cursor) may not support image content blocks diff --git a/apps/server/src/routes/event-history/common.ts b/apps/server/src/routes/event-history/common.ts new file mode 100644 index 00000000..bd0ad3fe --- /dev/null +++ b/apps/server/src/routes/event-history/common.ts @@ -0,0 +1,19 @@ +/** + * Common utilities for event history routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for event history operations */ +export const logger = createLogger('EventHistory'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/event-history/index.ts b/apps/server/src/routes/event-history/index.ts new file mode 100644 index 00000000..93297ddd --- /dev/null +++ b/apps/server/src/routes/event-history/index.ts @@ -0,0 +1,68 @@ +/** + * Event History routes - HTTP API for event history management + * + * Provides endpoints for: + * - Listing events with filtering + * - Getting individual event details + * - Deleting events + * - Clearing all events + * - Replaying events to test hooks + * + * Mounted at /api/event-history in the main server. + */ + +import { Router } from 'express'; +import type { EventHistoryService } from '../../services/event-history-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createListHandler } from './routes/list.js'; +import { createGetHandler } from './routes/get.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createClearHandler } from './routes/clear.js'; +import { createReplayHandler } from './routes/replay.js'; + +/** + * Create event history router with all endpoints + * + * Endpoints: + * - POST /list - List events with optional filtering + * - POST /get - Get a single event by ID + * - POST /delete - Delete an event by ID + * - POST /clear - Clear all events for a project + * - POST /replay - Replay an event to trigger hooks + * + * @param eventHistoryService - Instance of EventHistoryService + * @param settingsService - Instance of SettingsService (for replay) + * @returns Express Router configured with all event history endpoints + */ +export function createEventHistoryRoutes( + eventHistoryService: EventHistoryService, + settingsService: SettingsService +): Router { + const router = Router(); + + // List events with filtering + router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService)); + + // Get single event + router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService)); + + // Delete event + router.post( + '/delete', + validatePathParams('projectPath'), + createDeleteHandler(eventHistoryService) + ); + + // Clear all events + router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService)); + + // Replay event + router.post( + '/replay', + validatePathParams('projectPath'), + createReplayHandler(eventHistoryService, settingsService) + ); + + return router; +} diff --git a/apps/server/src/routes/event-history/routes/clear.ts b/apps/server/src/routes/event-history/routes/clear.ts new file mode 100644 index 00000000..c6e6bb58 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/clear.ts @@ -0,0 +1,33 @@ +/** + * POST /api/event-history/clear - Clear all events for a project + * + * Request body: { projectPath: string } + * Response: { success: true, cleared: number } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createClearHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const cleared = await eventHistoryService.clearEvents(projectPath); + + res.json({ + success: true, + cleared, + }); + } catch (error) { + logError(error, 'Clear events failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/delete.ts b/apps/server/src/routes/event-history/routes/delete.ts new file mode 100644 index 00000000..ea3f6b16 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/delete.ts @@ -0,0 +1,43 @@ +/** + * POST /api/event-history/delete - Delete an event by ID + * + * Request body: { projectPath: string, eventId: string } + * Response: { success: true } or { success: false, error: string } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId } = req.body as { + projectPath: string; + eventId: string; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + const deleted = await eventHistoryService.deleteEvent(projectPath, eventId); + + if (!deleted) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/get.ts b/apps/server/src/routes/event-history/routes/get.ts new file mode 100644 index 00000000..f892fd41 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/get.ts @@ -0,0 +1,46 @@ +/** + * POST /api/event-history/get - Get a single event by ID + * + * Request body: { projectPath: string, eventId: string } + * Response: { success: true, event: StoredEvent } or { success: false, error: string } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId } = req.body as { + projectPath: string; + eventId: string; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + const event = await eventHistoryService.getEvent(projectPath, eventId); + + if (!event) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + res.json({ + success: true, + event, + }); + } catch (error) { + logError(error, 'Get event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/list.ts b/apps/server/src/routes/event-history/routes/list.ts new file mode 100644 index 00000000..551594f2 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/list.ts @@ -0,0 +1,53 @@ +/** + * POST /api/event-history/list - List events for a project + * + * Request body: { + * projectPath: string, + * filter?: { + * trigger?: EventHookTrigger, + * featureId?: string, + * since?: string, + * until?: string, + * limit?: number, + * offset?: number + * } + * } + * Response: { success: true, events: StoredEventSummary[], total: number } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import type { EventHistoryFilter } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createListHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, filter } = req.body as { + projectPath: string; + filter?: EventHistoryFilter; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const events = await eventHistoryService.getEvents(projectPath, filter); + const total = await eventHistoryService.getEventCount(projectPath, { + ...filter, + limit: undefined, + offset: undefined, + }); + + res.json({ + success: true, + events, + total, + }); + } catch (error) { + logError(error, 'List events failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/replay.ts b/apps/server/src/routes/event-history/routes/replay.ts new file mode 100644 index 00000000..c6f27a40 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/replay.ts @@ -0,0 +1,234 @@ +/** + * POST /api/event-history/replay - Replay an event to trigger hooks + * + * Request body: { + * projectPath: string, + * eventId: string, + * hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks) + * } + * Response: { success: true, result: EventReplayResult } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError, logger } from '../common.js'; + +const execAsync = promisify(exec); + +/** Default timeout for shell commands (30 seconds) */ +const DEFAULT_SHELL_TIMEOUT = 30000; + +/** Default timeout for HTTP requests (10 seconds) */ +const DEFAULT_HTTP_TIMEOUT = 10000; + +interface HookContext { + featureId?: string; + featureName?: string; + projectPath?: string; + projectName?: string; + error?: string; + errorType?: string; + timestamp: string; + eventType: string; +} + +/** + * Substitute {{variable}} placeholders in a string + */ +function substituteVariables(template: string, context: HookContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof HookContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); +} + +/** + * Execute a single hook and return the result + */ +async function executeHook(hook: EventHook, context: HookContext): Promise { + const hookName = hook.name || hook.id; + const startTime = Date.now(); + + try { + if (hook.action.type === 'shell') { + const command = substituteVariables(hook.action.command, context); + const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT; + + logger.info(`Replaying shell hook "${hookName}": ${command}`); + + await execAsync(command, { + timeout, + maxBuffer: 1024 * 1024, + }); + + return { + hookId: hook.id, + hookName: hook.name, + success: true, + durationMs: Date.now() - startTime, + }; + } else if (hook.action.type === 'http') { + const url = substituteVariables(hook.action.url, context); + const method = hook.action.method || 'POST'; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (hook.action.headers) { + for (const [key, value] of Object.entries(hook.action.headers)) { + headers[key] = substituteVariables(value, context); + } + } + + let body: string | undefined; + if (hook.action.body) { + body = substituteVariables(hook.action.body, context); + } else if (method !== 'GET') { + body = JSON.stringify({ + eventType: context.eventType, + timestamp: context.timestamp, + featureId: context.featureId, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + }); + } + + logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT); + + const response = await fetch(url, { + method, + headers, + body: method !== 'GET' ? body : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + durationMs: Date.now() - startTime, + }; + } + + return { + hookId: hook.id, + hookName: hook.name, + success: true, + durationMs: Date.now() - startTime, + }; + } + + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: 'Unknown hook action type', + durationMs: Date.now() - startTime, + }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.name === 'AbortError' + ? 'Request timed out' + : error.message + : String(error); + + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: errorMessage, + durationMs: Date.now() - startTime, + }; + } +} + +export function createReplayHandler( + eventHistoryService: EventHistoryService, + settingsService: SettingsService +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId, hookIds } = req.body as { + projectPath: string; + eventId: string; + hookIds?: string[]; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + // Get the event + const event = await eventHistoryService.getEvent(projectPath, eventId); + if (!event) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + // Get hooks from settings + const settings = await settingsService.getGlobalSettings(); + let hooks = settings.eventHooks || []; + + // Filter to matching trigger and enabled hooks + hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger); + + // If specific hook IDs requested, filter to those + if (hookIds && hookIds.length > 0) { + hooks = hooks.filter((h) => hookIds.includes(h.id)); + } + + // Build context for variable substitution + const context: HookContext = { + featureId: event.featureId, + featureName: event.featureName, + projectPath: event.projectPath, + projectName: event.projectName, + error: event.error, + errorType: event.errorType, + timestamp: event.timestamp, + eventType: event.trigger, + }; + + // Execute all hooks in parallel + const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context))); + + const result: EventReplayResult = { + eventId, + hooksTriggered: hooks.length, + hookResults, + }; + + logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`); + + res.json({ + success: true, + result, + }); + } catch (error) { + logError(error, 'Replay event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index e0435f35..439ab6a9 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -4,6 +4,8 @@ import { Router } from 'express'; import { FeatureLoader } from '../../services/feature-loader.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { createListHandler } from './routes/list.js'; import { createGetHandler } from './routes/get.js'; @@ -15,12 +17,20 @@ import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; -export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { +export function createFeaturesRoutes( + featureLoader: FeatureLoader, + settingsService?: SettingsService, + events?: EventEmitter +): Router { const router = Router(); router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader)); router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); - router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader)); + router.post( + '/create', + validatePathParams('projectPath'), + createCreateHandler(featureLoader, events) + ); router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); router.post( '/bulk-update', @@ -35,7 +45,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); - router.post('/generate-title', createGenerateTitleHandler()); + router.post('/generate-title', createGenerateTitleHandler(settingsService)); return router; } diff --git a/apps/server/src/routes/features/routes/bulk-delete.ts b/apps/server/src/routes/features/routes/bulk-delete.ts index 555515ae..851c288c 100644 --- a/apps/server/src/routes/features/routes/bulk-delete.ts +++ b/apps/server/src/routes/features/routes/bulk-delete.ts @@ -30,19 +30,27 @@ export function createBulkDeleteHandler(featureLoader: FeatureLoader) { return; } - const results = await Promise.all( - featureIds.map(async (featureId) => { - const success = await featureLoader.delete(projectPath, featureId); - if (success) { - return { featureId, success: true }; - } - return { - featureId, - success: false, - error: 'Deletion failed. Check server logs for details.', - }; - }) - ); + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + const results: BulkDeleteResult[] = []; + + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + const success = await featureLoader.delete(projectPath, featureId); + if (success) { + return { featureId, success: true }; + } + return { + featureId, + success: false, + error: 'Deletion failed. Check server logs for details.', + }; + }) + ); + results.push(...batchResults); + } const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); const failureCount = results.length - successCount; diff --git a/apps/server/src/routes/features/routes/bulk-update.ts b/apps/server/src/routes/features/routes/bulk-update.ts index a1c97e72..3fb8cc9f 100644 --- a/apps/server/src/routes/features/routes/bulk-update.ts +++ b/apps/server/src/routes/features/routes/bulk-update.ts @@ -43,17 +43,36 @@ export function createBulkUpdateHandler(featureLoader: FeatureLoader) { const results: BulkUpdateResult[] = []; const updatedFeatures: Feature[] = []; - for (const featureId of featureIds) { - try { - const updated = await featureLoader.update(projectPath, featureId, updates); - results.push({ featureId, success: true }); - updatedFeatures.push(updated); - } catch (error) { - results.push({ - featureId, - success: false, - error: getErrorMessage(error), - }); + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + try { + const updated = await featureLoader.update(projectPath, featureId, updates); + return { featureId, success: true as const, feature: updated }; + } catch (error) { + return { + featureId, + success: false as const, + error: getErrorMessage(error), + }; + } + }) + ); + + for (const result of batchResults) { + if (result.success) { + results.push({ featureId: result.featureId, success: true }); + updatedFeatures.push(result.feature); + } else { + results.push({ + featureId: result.featureId, + success: false, + error: result.error, + }); + } } } diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 5f04ecdb..a4df45a6 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -4,10 +4,11 @@ import type { Request, Response } from 'express'; import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { EventEmitter } from '../../../lib/events.js'; import type { Feature } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; -export function createCreateHandler(featureLoader: FeatureLoader) { +export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { projectPath, feature } = req.body as { @@ -23,7 +24,30 @@ export function createCreateHandler(featureLoader: FeatureLoader) { return; } + // Check for duplicate title if title is provided + if (feature.title && feature.title.trim()) { + const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); + if (duplicate) { + res.status(409).json({ + success: false, + error: `A feature with title "${feature.title}" already exists`, + duplicateFeatureId: duplicate.id, + }); + return; + } + } + const created = await featureLoader.create(projectPath, feature); + + // Emit feature_created event for hooks + if (events) { + events.emit('feature:created', { + featureId: created.id, + featureName: created.name, + projectPath, + }); + } + res.json({ success: true, feature: created }); } catch (error) { logError(error, 'Create feature failed'); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index a838e5aa..e7603eb8 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -9,6 +9,8 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { simpleQuery } from '../../../providers/simple-query-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getPromptCustomization } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateTitle'); @@ -26,16 +28,9 @@ interface GenerateTitleErrorResponse { error: string; } -const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description. - -Rules: -- Output ONLY the title, nothing else -- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation") -- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.) -- No quotes, periods, or extra formatting -- Capture the essence of the feature in a scannable way`; - -export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise { +export function createGenerateTitleHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { const { description } = req.body as GenerateTitleRequestBody; @@ -61,11 +56,15 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`); + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); + const systemPrompt = prompts.titleGeneration.systemPrompt; + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; // Use simpleQuery - provider abstraction handles all the streaming/extraction const result = await simpleQuery({ - prompt: `${SYSTEM_PROMPT}\n\n${userPrompt}`, + prompt: `${systemPrompt}\n\n${userPrompt}`, model: CLAUDE_MODEL_MAP.haiku, cwd: process.cwd(), maxTurns: 1, diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 1a89cda3..a5b532c1 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -4,8 +4,14 @@ import type { Request, Response } from 'express'; import { FeatureLoader } from '../../../services/feature-loader.js'; -import type { Feature } from '@automaker/types'; +import type { Feature, FeatureStatus } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('features/update'); + +// Statuses that should trigger syncing to app_spec.txt +const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed']; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { @@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } + // Check for duplicate title if title is being updated + if (updates.title && updates.title.trim()) { + const duplicate = await featureLoader.findDuplicateTitle( + projectPath, + updates.title, + featureId // Exclude the current feature from duplicate check + ); + if (duplicate) { + res.status(409).json({ + success: false, + error: `A feature with title "${updates.title}" already exists`, + duplicateFeatureId: duplicate.id, + }); + return; + } + } + + // Get the current feature to detect status changes + const currentFeature = await featureLoader.get(projectPath, featureId); + const previousStatus = currentFeature?.status as FeatureStatus | undefined; + const newStatus = updates.status as FeatureStatus | undefined; + const updated = await featureLoader.update( projectPath, featureId, @@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { enhancementMode, preEnhancementDescription ); + + // Trigger sync to app_spec.txt when status changes to verified or completed + if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) { + try { + const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated); + if (synced) { + logger.info( + `Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}` + ); + } + } catch (syncError) { + // Log the sync error but don't fail the update operation + logger.error(`Failed to sync feature to app_spec.txt:`, syncError); + } + } + res.json({ success: true, feature: updated }); } catch (error) { logError(error, 'Update feature failed'); diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 14de437b..e7d83d99 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -30,11 +30,11 @@ import { writeValidation } from '../../../lib/validation-storage.js'; import { streamingQuery } from '../../../providers/simple-query-service.js'; import { issueValidationSchema, - ISSUE_VALIDATION_SYSTEM_PROMPT, buildValidationPrompt, ValidationComment, ValidationLinkedPR, } from './validation-schema.js'; +import { getPromptCustomization } from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, clearValidationStatus, @@ -117,13 +117,17 @@ async function runValidation( let responseText = ''; + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]'); + const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt; + // Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't) const useStructuredOutput = isClaudeModel(model) || isCodexModel(model); // Build the final prompt - for Cursor, include system prompt and JSON schema instructions let finalPrompt = basePrompt; if (!useStructuredOutput) { - finalPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} + finalPrompt = `${issueValidationSystemPrompt} CRITICAL INSTRUCTIONS: 1. DO NOT write any files. Return the JSON in your response only. @@ -167,7 +171,7 @@ ${basePrompt}`; prompt: finalPrompt, model: model as string, cwd: projectPath, - systemPrompt: useStructuredOutput ? ISSUE_VALIDATION_SYSTEM_PROMPT : undefined, + systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined, abortController, thinkingLevel: effectiveThinkingLevel, reasoningEffort: effectiveReasoningEffort, diff --git a/apps/server/src/routes/github/routes/validation-schema.ts b/apps/server/src/routes/github/routes/validation-schema.ts index 010fcd7f..9ba48a2b 100644 --- a/apps/server/src/routes/github/routes/validation-schema.ts +++ b/apps/server/src/routes/github/routes/validation-schema.ts @@ -1,8 +1,11 @@ /** - * Issue Validation Schema and System Prompt + * Issue Validation Schema and Prompt Building * * Defines the JSON schema for Claude's structured output and - * the system prompt that guides the validation process. + * helper functions for building validation prompts. + * + * Note: The system prompt is now centralized in @automaker/prompts + * and accessed via getPromptCustomization() in validate-issue.ts */ /** @@ -82,76 +85,6 @@ export const issueValidationSchema = { additionalProperties: false, } as const; -/** - * System prompt that guides Claude in validating GitHub issues. - * Instructs the model to use read-only tools to analyze the codebase. - */ -export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase. - -Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase. - -## Validation Process - -1. **Read the issue carefully** - Understand what is being reported or requested -2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords -3. **Examine the code** - Use Read to look at the actual implementation in relevant files -4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff \` to review the changes -5. **Form your verdict** - Based on your analysis, determine if the issue is valid - -## Verdicts - -- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable. - -- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior. - -- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field. - -## For Bug Reports, Check: -- Do the referenced files/components exist? -- Does the code match what the issue describes? -- Is the described behavior actually a bug or expected? -- Can you locate the code that would cause the reported issue? - -## For Feature Requests, Check: -- Does the feature already exist? -- Is the implementation location clear? -- Is the request technically feasible given the codebase structure? - -## Analyzing Linked Pull Requests - -When an issue has linked PRs (especially open ones), you MUST analyze them: - -1. **Run \`gh pr diff \`** to see what changes the PR makes -2. **Run \`gh pr view \`** to see PR description and status -3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem? -4. **Provide a recommendation**: - - \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged. - - \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems. - - \`no_pr\`: No relevant PR exists for this issue. - -5. **Include prAnalysis in your response** with: - - hasOpenPR: true/false - - prFixesIssue: true/false (based on diff analysis) - - prNumber: the PR number you analyzed - - prSummary: brief description of what the PR changes - - recommendation: one of the above values - -## Response Guidelines - -- **Always include relatedFiles** when you find relevant code -- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code -- **Provide a suggestedFix** when you have a clear idea of how to address the issue -- **Use missingInfo** when the verdict is needs_clarification to list what's needed -- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work -- **Set estimatedComplexity** to help prioritize: - - trivial: Simple text changes, one-line fixes - - simple: Small changes to one file - - moderate: Changes to multiple files or moderate logic changes - - complex: Significant refactoring or new feature implementation - - very_complex: Major architectural changes or cross-cutting concerns - -Be thorough in your analysis but focus on files that are directly relevant to the issue.`; - /** * Comment data structure for validation prompt */ diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts index ee5f7d53..8e5a89c9 100644 --- a/apps/server/src/routes/health/routes/environment.ts +++ b/apps/server/src/routes/health/routes/environment.ts @@ -9,12 +9,14 @@ import type { Request, Response } from 'express'; export interface EnvironmentResponse { isContainerized: boolean; + skipSandboxWarning?: boolean; } export function createEnvironmentHandler() { return (_req: Request, res: Response): void => { res.json({ isContainerized: process.env.IS_CONTAINERIZED === 'true', + skipSandboxWarning: process.env.AUTOMAKER_SKIP_SANDBOX_WARNING === 'true', } satisfies EnvironmentResponse); }; } diff --git a/apps/server/src/routes/notifications/common.ts b/apps/server/src/routes/notifications/common.ts new file mode 100644 index 00000000..707e3a0d --- /dev/null +++ b/apps/server/src/routes/notifications/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for notification routes + * + * Provides logger and error handling utilities shared across all notification endpoints. + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for notification-related operations */ +export const logger = createLogger('Notifications'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/notifications/index.ts b/apps/server/src/routes/notifications/index.ts new file mode 100644 index 00000000..2def111a --- /dev/null +++ b/apps/server/src/routes/notifications/index.ts @@ -0,0 +1,62 @@ +/** + * Notifications routes - HTTP API for project-level notifications + * + * Provides endpoints for: + * - Listing notifications + * - Getting unread count + * - Marking notifications as read + * - Dismissing notifications + * + * All endpoints use handler factories that receive the NotificationService instance. + * Mounted at /api/notifications in the main server. + */ + +import { Router } from 'express'; +import type { NotificationService } from '../../services/notification-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createListHandler } from './routes/list.js'; +import { createUnreadCountHandler } from './routes/unread-count.js'; +import { createMarkReadHandler } from './routes/mark-read.js'; +import { createDismissHandler } from './routes/dismiss.js'; + +/** + * Create notifications router with all endpoints + * + * Endpoints: + * - POST /list - List all notifications for a project + * - POST /unread-count - Get unread notification count + * - POST /mark-read - Mark notification(s) as read + * - POST /dismiss - Dismiss notification(s) + * + * @param notificationService - Instance of NotificationService + * @returns Express Router configured with all notification endpoints + */ +export function createNotificationsRoutes(notificationService: NotificationService): Router { + const router = Router(); + + // List notifications + router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService)); + + // Get unread count + router.post( + '/unread-count', + validatePathParams('projectPath'), + createUnreadCountHandler(notificationService) + ); + + // Mark as read (single or all) + router.post( + '/mark-read', + validatePathParams('projectPath'), + createMarkReadHandler(notificationService) + ); + + // Dismiss (single or all) + router.post( + '/dismiss', + validatePathParams('projectPath'), + createDismissHandler(notificationService) + ); + + return router; +} diff --git a/apps/server/src/routes/notifications/routes/dismiss.ts b/apps/server/src/routes/notifications/routes/dismiss.ts new file mode 100644 index 00000000..c609f170 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/dismiss.ts @@ -0,0 +1,53 @@ +/** + * POST /api/notifications/dismiss - Dismiss notification(s) + * + * Request body: { projectPath: string, notificationId?: string } + * - If notificationId provided: dismisses that notification + * - If notificationId not provided: dismisses all notifications + * + * Response: { success: true, dismissed: boolean | count: number } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/dismiss + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createDismissHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, notificationId } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If notificationId provided, dismiss single notification + if (notificationId) { + const dismissed = await notificationService.dismissNotification( + projectPath, + notificationId + ); + if (!dismissed) { + res.status(404).json({ success: false, error: 'Notification not found' }); + return; + } + res.json({ success: true, dismissed: true }); + return; + } + + // Otherwise dismiss all + const count = await notificationService.dismissAll(projectPath); + res.json({ success: true, count }); + } catch (error) { + logError(error, 'Dismiss failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/list.ts b/apps/server/src/routes/notifications/routes/list.ts new file mode 100644 index 00000000..46197fe9 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/list.ts @@ -0,0 +1,39 @@ +/** + * POST /api/notifications/list - List all notifications for a project + * + * Request body: { projectPath: string } + * Response: { success: true, notifications: Notification[] } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/list + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createListHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const notifications = await notificationService.getNotifications(projectPath); + + res.json({ + success: true, + notifications, + }); + } catch (error) { + logError(error, 'List notifications failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/mark-read.ts b/apps/server/src/routes/notifications/routes/mark-read.ts new file mode 100644 index 00000000..4c9bfeb5 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/mark-read.ts @@ -0,0 +1,50 @@ +/** + * POST /api/notifications/mark-read - Mark notification(s) as read + * + * Request body: { projectPath: string, notificationId?: string } + * - If notificationId provided: marks that notification as read + * - If notificationId not provided: marks all notifications as read + * + * Response: { success: true, count?: number, notification?: Notification } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/mark-read + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createMarkReadHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, notificationId } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If notificationId provided, mark single notification + if (notificationId) { + const notification = await notificationService.markAsRead(projectPath, notificationId); + if (!notification) { + res.status(404).json({ success: false, error: 'Notification not found' }); + return; + } + res.json({ success: true, notification }); + return; + } + + // Otherwise mark all as read + const count = await notificationService.markAllAsRead(projectPath); + res.json({ success: true, count }); + } catch (error) { + logError(error, 'Mark read failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/unread-count.ts b/apps/server/src/routes/notifications/routes/unread-count.ts new file mode 100644 index 00000000..98d8e198 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/unread-count.ts @@ -0,0 +1,39 @@ +/** + * POST /api/notifications/unread-count - Get unread notification count + * + * Request body: { projectPath: string } + * Response: { success: true, count: number } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/unread-count + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createUnreadCountHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const count = await notificationService.getUnreadCount(projectPath); + + res.json({ + success: true, + count, + }); + } catch (error) { + logError(error, 'Get unread count failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 693eba8b..1eeb4ae6 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -4,12 +4,58 @@ import type { Request, Response } from 'express'; import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; +import { getAllRunningGenerations } from '../../app-spec/common.js'; +import path from 'path'; import { getErrorMessage, logError } from '../common.js'; export function createIndexHandler(autoModeService: AutoModeService) { return async (_req: Request, res: Response): Promise => { try { - const runningAgents = await autoModeService.getRunningAgents(); + const runningAgents = [...(await autoModeService.getRunningAgents())]; + const backlogPlanStatus = getBacklogPlanStatus(); + const backlogPlanDetails = getRunningDetails(); + + if (backlogPlanStatus.isRunning && backlogPlanDetails) { + runningAgents.push({ + featureId: `backlog-plan:${backlogPlanDetails.projectPath}`, + projectPath: backlogPlanDetails.projectPath, + projectName: path.basename(backlogPlanDetails.projectPath), + isAutoMode: false, + title: 'Backlog plan', + description: backlogPlanDetails.prompt, + }); + } + + // Add spec/feature generation tasks + const specGenerations = getAllRunningGenerations(); + for (const generation of specGenerations) { + let title: string; + let description: string; + + switch (generation.type) { + case 'feature_generation': + title = 'Generating features from spec'; + description = 'Creating features from the project specification'; + break; + case 'sync': + title = 'Syncing spec with code'; + description = 'Updating spec from codebase and completed features'; + break; + default: + title = 'Regenerating spec'; + description = 'Analyzing project and generating specification'; + } + + runningAgents.push({ + featureId: `spec-generation:${generation.projectPath}`, + projectPath: generation.projectPath, + projectName: path.basename(generation.projectPath), + isAutoMode: false, + title, + description, + }); + } res.json({ success: true, diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index aafbc5b1..a04227d8 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -12,6 +12,18 @@ import type { Request, Response } from 'express'; import type { SettingsService } from '../../../services/settings-service.js'; import type { GlobalSettings } from '../../../types/settings.js'; import { getErrorMessage, logError, logger } from '../common.js'; +import { setLogLevel, LogLevel } from '@automaker/utils'; +import { setRequestLoggingEnabled } from '../../../index.js'; + +/** + * Map server log level string to LogLevel enum + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; /** * Create handler factory for PUT /api/settings/global @@ -46,6 +58,23 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { const settings = await settingsService.updateGlobalSettings(updates); + // Apply server log level if it was updated + if ('serverLogLevel' in updates && updates.serverLogLevel) { + const level = LOG_LEVEL_MAP[updates.serverLogLevel]; + if (level !== undefined) { + setLogLevel(level); + logger.info(`Server log level changed to: ${updates.serverLogLevel}`); + } + } + + // Apply request logging setting if it was updated + if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') { + setRequestLoggingEnabled(updates.enableRequestLogging); + logger.info( + `HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}` + ); + } + res.json({ success: true, settings, diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 0766c41b..08a3628b 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -15,7 +15,7 @@ import { FeatureLoader } from '../../services/feature-loader.js'; import { getAppSpecPath } from '@automaker/platform'; import * as secureFs from '../../lib/secure-fs.js'; import type { SettingsService } from '../../services/settings-service.js'; -import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; const logger = createLogger('Suggestions'); @@ -137,11 +137,15 @@ export async function generateSuggestions( modelOverride?: string, thinkingLevelOverride?: ThinkingLevel ): Promise { + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[Suggestions]'); + + // Map suggestion types to their prompts const typePrompts: Record = { - features: 'Analyze this project and suggest new features that would add value.', - refactoring: 'Analyze this project and identify refactoring opportunities.', - security: 'Analyze this project for security vulnerabilities and suggest fixes.', - performance: 'Analyze this project for performance issues and suggest optimizations.', + features: prompts.suggestions.featuresPrompt, + refactoring: prompts.suggestions.refactoringPrompt, + security: prompts.suggestions.securityPrompt, + performance: prompts.suggestions.performancePrompt, }; // Load existing context to avoid duplicates @@ -151,15 +155,7 @@ export async function generateSuggestions( ${existingContext} ${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''} -Look at the codebase and provide 3-5 concrete suggestions. - -For each suggestion, provide: -1. A category (e.g., "User Experience", "Security", "Performance") -2. A clear description of what to implement -3. Priority (1=high, 2=medium, 3=low) -4. Brief reasoning for why this would help - -The response will be automatically formatted as structured JSON.`; +${prompts.suggestions.baseTemplate}`; // Don't send initial message - let the agent output speak for itself // The first agent message will be captured as an info entry diff --git a/apps/server/src/routes/worktree/routes/dev-server-logs.ts b/apps/server/src/routes/worktree/routes/dev-server-logs.ts index 66dfed92..79d3553b 100644 --- a/apps/server/src/routes/worktree/routes/dev-server-logs.ts +++ b/apps/server/src/routes/worktree/routes/dev-server-logs.ts @@ -34,6 +34,7 @@ export function createGetDevServerLogsHandler() { result: { worktreePath: result.result.worktreePath, port: result.result.port, + url: result.result.url, logs: result.result.logs, startedAt: result.result.startedAt, }, diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index a7c12f98..96782d64 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -16,10 +16,27 @@ import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; +import { + checkGitHubRemote, + type GitHubRemoteStatus, +} from '../../github/routes/check-github-remote.js'; const execAsync = promisify(exec); const logger = createLogger('Worktree'); +/** + * Cache for GitHub remote status per project path. + * This prevents repeated "no git remotes found" warnings when polling + * projects that don't have a GitHub remote configured. + */ +interface GitHubRemoteCacheEntry { + status: GitHubRemoteStatus; + checkedAt: number; +} + +const githubRemoteCache = new Map(); +const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + interface WorktreeInfo { path: string; branch: string; @@ -121,23 +138,63 @@ async function scanWorktreesDirectory( return discovered; } +/** + * Get cached GitHub remote status for a project, or check and cache it. + * Returns null if gh CLI is not available. + */ +async function getGitHubRemoteStatus(projectPath: string): Promise { + // Check if gh CLI is available first + const ghAvailable = await isGhCliAvailable(); + if (!ghAvailable) { + return null; + } + + const now = Date.now(); + const cached = githubRemoteCache.get(projectPath); + + // Return cached result if still valid + if (cached && now - cached.checkedAt < GITHUB_REMOTE_CACHE_TTL_MS) { + return cached.status; + } + + // Check GitHub remote and cache the result + const status = await checkGitHubRemote(projectPath); + githubRemoteCache.set(projectPath, { + status, + checkedAt: Date.now(), + }); + + return status; +} + /** * Fetch open PRs from GitHub and create a map of branch name to PR info. * This allows detecting PRs that were created outside the app. + * + * Uses cached GitHub remote status to avoid repeated warnings when the + * project doesn't have a GitHub remote configured. */ async function fetchGitHubPRs(projectPath: string): Promise> { const prMap = new Map(); try { - // Check if gh CLI is available - const ghAvailable = await isGhCliAvailable(); - if (!ghAvailable) { + // Check GitHub remote status (uses cache to avoid repeated warnings) + const remoteStatus = await getGitHubRemoteStatus(projectPath); + + // If gh CLI not available or no GitHub remote, return empty silently + if (!remoteStatus || !remoteStatus.hasGitHubRemote) { return prMap; } + // Use -R flag with owner/repo for more reliable PR fetching + const repoFlag = + remoteStatus.owner && remoteStatus.repo + ? `-R ${remoteStatus.owner}/${remoteStatus.repo}` + : ''; + // Fetch open PRs from GitHub const { stdout } = await execAsync( - 'gh pr list --state open --json number,title,url,state,headRefName,createdAt --limit 1000', + `gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`, { cwd: projectPath, env: execEnv, timeout: 15000 } ); @@ -170,9 +227,10 @@ async function fetchGitHubPRs(projectPath: string): Promise => { try { - const { projectPath, includeDetails } = req.body as { + const { projectPath, includeDetails, forceRefreshGitHub } = req.body as { projectPath: string; includeDetails?: boolean; + forceRefreshGitHub?: boolean; }; if (!projectPath) { @@ -180,6 +238,12 @@ export function createListHandler() { return; } + // Clear GitHub remote cache if force refresh requested + // This allows users to re-check for GitHub remote after adding one + if (forceRefreshGitHub) { + githubRemoteCache.delete(projectPath); + } + if (!(await isGitRepo(projectPath))) { res.json({ success: true, worktrees: [] }); return; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 29caa4e1..454b7ec0 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -29,6 +29,10 @@ import { appendLearning, recordMemoryUsage, createLogger, + atomicWriteJson, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, } from '@automaker/utils'; const logger = createLogger('AutoMode'); @@ -60,6 +64,7 @@ import { getMCPServersFromSettings, getPromptCustomization, } from '../lib/settings-helpers.js'; +import { getNotificationService } from './notification-service.js'; const execAsync = promisify(exec); @@ -386,6 +391,7 @@ export class AutoModeService { this.emitAutoModeEvent('auto_mode_error', { error: errorInfo.message, errorType: errorInfo.type, + projectPath, }); }); } @@ -579,6 +585,9 @@ export class AutoModeService { '[AutoMode]' ); + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + // Build the prompt - use continuation prompt if provided (for recovery after plan approval) let prompt: string; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files @@ -604,7 +613,7 @@ export class AutoModeService { logger.info(`Using continuation prompt for feature ${featureId}`); } else { // Normal flow: build prompt with planning phase - const featurePrompt = this.buildFeaturePrompt(feature); + const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); const planningPrefix = await this.getPlanningPromptPrefix(feature); prompt = planningPrefix + featurePrompt; @@ -783,6 +792,9 @@ export class AutoModeService { ): Promise { logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`); + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + // Load context files once with feature context for smart memory selection const contextResult = await loadContextFiles({ projectPath, @@ -827,7 +839,12 @@ export class AutoModeService { }); // Build prompt for this pipeline step - const prompt = this.buildPipelineStepPrompt(step, feature, previousContext); + const prompt = this.buildPipelineStepPrompt( + step, + feature, + previousContext, + prompts.taskExecution + ); // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); @@ -882,14 +899,18 @@ export class AutoModeService { private buildPipelineStepPrompt( step: PipelineStep, feature: Feature, - previousContext: string + previousContext: string, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } ): string { let prompt = `## Pipeline Step: ${step.name} This is an automated pipeline step following the initial feature implementation. ### Feature Context -${this.buildFeaturePrompt(feature)} +${this.buildFeaturePrompt(feature, taskExecutionPrompts)} `; @@ -1279,6 +1300,9 @@ Complete the pipeline step instructions above. Review the previous work and appl '[AutoMode]' ); + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt const contextResult = await loadContextFiles({ projectPath, @@ -1296,7 +1320,7 @@ Complete the pipeline step instructions above. Review the previous work and appl // Build complete prompt with feature info, previous context, and follow-up instructions let fullPrompt = `## Follow-up on Feature Implementation -${feature ? this.buildFeaturePrompt(feature) : `**Feature ID:** ${featureId}`} +${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`} `; if (previousContext) { @@ -1396,13 +1420,13 @@ Address the follow-up instructions above. Review the previous work and make the allImagePaths.push(...allPaths); } - // Save updated feature.json with new images + // Save updated feature.json with new images (atomic write with backup) if (copiedImagePaths.length > 0 && feature) { const featureDirForSave = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDirForSave, 'feature.json'); try { - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); } catch (error) { logger.error(`Failed to save feature.json:`, error); } @@ -1529,6 +1553,7 @@ Address the follow-up instructions above. Review the previous work and make the message: allPassed ? 'All verification checks passed' : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, + projectPath, }); return allPassed; @@ -1602,6 +1627,7 @@ Address the follow-up instructions above. Review the previous work and make the featureId, passes: true, message: `Changes committed: ${hash.trim().substring(0, 8)}`, + projectPath, }); return hash.trim(); @@ -1888,13 +1914,17 @@ Format your response as a structured markdown document.`; content: editedPlan || feature.planSpec.content, }); - // Build continuation prompt and re-run the feature + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + + // Build continuation prompt using centralized template const planContent = editedPlan || feature.planSpec.content || ''; - let continuationPrompt = `The plan/specification has been approved. `; - if (feedback) { - continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; - } - continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`; + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace( + /\{\{userFeedback\}\}/g, + feedback || '' + ); + continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); logger.info(`Starting recovery execution for feature ${featureId}`); @@ -2066,8 +2096,20 @@ Format your response as a structured markdown document.`; const featurePath = path.join(featureDir, 'feature.json'); try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + feature.status = status; feature.updatedAt = new Date().toISOString(); // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) @@ -2078,9 +2120,41 @@ Format your response as a structured markdown document.`; // Clear the timestamp when moving to other statuses feature.justFinishedAt = undefined; } - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); - } catch { - // Feature file may not exist + + // Use atomic write with backup support + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Create notifications for important status changes + const notificationService = getNotificationService(); + if (status === 'waiting_approval') { + await notificationService.createNotification({ + type: 'feature_waiting_approval', + title: 'Feature Ready for Review', + message: `"${feature.name || featureId}" is ready for your review and approval.`, + featureId, + projectPath, + }); + } else if (status === 'verified') { + await notificationService.createNotification({ + type: 'feature_verified', + title: 'Feature Verified', + message: `"${feature.name || featureId}" has been verified and is complete.`, + featureId, + projectPath, + }); + } + + // Sync completed/verified features to app_spec.txt + if (status === 'verified' || status === 'completed') { + try { + await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); + } catch (syncError) { + // Log but don't fail the status update if sync fails + logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); + } + } + } catch (error) { + logger.error(`Failed to update feature status for ${featureId}:`, error); } } @@ -2092,11 +2166,24 @@ Format your response as a structured markdown document.`; featureId: string, updates: Partial ): Promise { - const featurePath = path.join(projectPath, '.automaker', 'features', featureId, 'feature.json'); + // Use getFeatureDir helper for consistent path resolution + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } // Initialize planSpec if it doesn't exist if (!feature.planSpec) { @@ -2116,7 +2203,9 @@ Format your response as a structured markdown document.`; } feature.updatedAt = new Date().toISOString(); - await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + + // Use atomic write with backup support + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); } catch (error) { logger.error(`Failed to update planSpec for ${featureId}:`, error); } @@ -2133,25 +2222,34 @@ Format your response as a structured markdown document.`; const allFeatures: Feature[] = []; const pendingFeatures: Feature[] = []; - // Load all features (for dependency checking) + // Load all features (for dependency checking) with recovery support for (const entry of entries) { if (entry.isDirectory()) { const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data); - allFeatures.push(feature); - // Track pending features separately - if ( - feature.status === 'pending' || - feature.status === 'ready' || - feature.status === 'backlog' - ) { - pendingFeatures.push(feature); - } - } catch { - // Skip invalid features + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${entry.name}`, logger); + + const feature = result.data; + if (!feature) { + // Skip features that couldn't be loaded or recovered + continue; + } + + allFeatures.push(feature); + + // Track pending features separately + if ( + feature.status === 'pending' || + feature.status === 'ready' || + feature.status === 'backlog' + ) { + pendingFeatures.push(feature); } } } @@ -2225,7 +2323,13 @@ Format your response as a structured markdown document.`; return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; } - private buildFeaturePrompt(feature: Feature): string { + private buildFeaturePrompt( + feature: Feature, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } + ): string { const title = this.extractTitleFromDescription(feature.description); let prompt = `## Feature Implementation Task @@ -2267,80 +2371,10 @@ You can use the Read tool to view these images at any time during implementation // Add verification instructions based on testing mode if (feature.skipTests) { // Manual verification - just implement the feature - prompt += ` -## Instructions - -Implement this feature by: -1. First, explore the codebase to understand the existing structure -2. Plan your implementation approach -3. Write the necessary code changes -4. Ensure the code follows existing patterns and conventions - -When done, wrap your final summary in tags like this: - - -## Summary: [Feature Title] - -### Changes Implemented -- [List of changes made] - -### Files Modified -- [List of files] - -### Notes for Developer -- [Any important notes] - - -This helps parse your summary correctly in the output logs.`; + prompt += `\n${taskExecutionPrompts.implementationInstructions}`; } else { // Automated testing - implement and verify with Playwright - prompt += ` -## Instructions - -Implement this feature by: -1. First, explore the codebase to understand the existing structure -2. Plan your implementation approach -3. Write the necessary code changes -4. Ensure the code follows existing patterns and conventions - -## Verification with Playwright (REQUIRED) - -After implementing the feature, you MUST verify it works correctly using Playwright: - -1. **Create a temporary Playwright test** to verify the feature works as expected -2. **Run the test** to confirm the feature is working -3. **Delete the test file** after verification - this is a temporary verification test, not a permanent test suite addition - -Example verification workflow: -\`\`\`bash -# Create a simple verification test -npx playwright test my-verification-test.spec.ts - -# After successful verification, delete the test -rm my-verification-test.spec.ts -\`\`\` - -The test should verify the core functionality of the feature. If the test fails, fix the implementation and re-test. - -When done, wrap your final summary in tags like this: - - -## Summary: [Feature Title] - -### Changes Implemented -- [List of changes made] - -### Files Modified -- [List of files] - -### Verification Status -- [Describe how the feature was verified with Playwright] - -### Notes for Developer -- [Any important notes] - - -This helps parse your summary correctly in the output logs.`; + prompt += `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; } return prompt; @@ -2910,6 +2944,12 @@ After generating the revised spec, output: `Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}` ); + // Get customized prompts for task execution + const taskPrompts = await getPromptCustomization( + this.settingsService, + '[AutoMode]' + ); + // Execute each task with a separate agent for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { const task = parsedTasks[taskIndex]; @@ -2941,6 +2981,7 @@ After generating the revised spec, output: parsedTasks, taskIndex, approvedPlanContent, + taskPrompts.taskExecution.taskPromptTemplate, userFeedback ); @@ -3023,15 +3064,21 @@ After generating the revised spec, output: `No parsed tasks, using single-agent execution for feature ${featureId}` ); - const continuationPrompt = `The plan/specification has been approved. Now implement it. -${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''} -## Approved Plan - -${approvedPlanContent} - -## Instructions - -Implement all the changes described in the plan above.`; + // Get customized prompts for continuation + const taskPrompts = await getPromptCustomization( + this.settingsService, + '[AutoMode]' + ); + let continuationPrompt = + taskPrompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace( + /\{\{userFeedback\}\}/g, + userFeedback || '' + ); + continuationPrompt = continuationPrompt.replace( + /\{\{approvedPlan\}\}/g, + approvedPlanContent + ); const continuationStream = provider.executeQuery({ prompt: continuationPrompt, @@ -3151,17 +3198,16 @@ Implement all the changes described in the plan above.`; throw new Error(`Feature ${featureId} not found`); } - const prompt = `## Continuing Feature Implementation + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); -${this.buildFeaturePrompt(feature)} + // Build the feature prompt + const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); -## Previous Context -The following is the output from a previous implementation attempt. Continue from where you left off: - -${context} - -## Instructions -Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; + // Use the resume feature template with variable substitution + let prompt = prompts.taskExecution.resumeFeatureTemplate; + prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt); + prompt = prompt.replace(/\{\{previousContext\}\}/g, context); return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { continuationPrompt: prompt, @@ -3282,68 +3328,42 @@ Review the previous work and continue the implementation. If the feature appears allTasks: ParsedTask[], taskIndex: number, planContent: string, + taskPromptTemplate: string, userFeedback?: string ): string { const completedTasks = allTasks.slice(0, taskIndex); const remainingTasks = allTasks.slice(taskIndex + 1); - let prompt = `# Task Execution: ${task.id} + // Build completed tasks string + const completedTasksStr = + completedTasks.length > 0 + ? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n` + : ''; -You are executing a specific task as part of a larger feature implementation. + // Build remaining tasks string + const remainingTasksStr = + remainingTasks.length > 0 + ? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks + .slice(0, 3) + .map((t) => `- [ ] ${t.id}: ${t.description}`) + .join( + '\n' + )}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n` + : ''; -## Your Current Task + // Build user feedback string + const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : ''; -**Task ID:** ${task.id} -**Description:** ${task.description} -${task.filePath ? `**Primary File:** ${task.filePath}` : ''} -${task.phase ? `**Phase:** ${task.phase}` : ''} - -## Context - -`; - - // Show what's already done - if (completedTasks.length > 0) { - prompt += `### Already Completed (${completedTasks.length} tasks) -${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')} - -`; - } - - // Show remaining tasks - if (remainingTasks.length > 0) { - prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining) -${remainingTasks - .slice(0, 3) - .map((t) => `- [ ] ${t.id}: ${t.description}`) - .join('\n')} -${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''} - -`; - } - - // Add user feedback if any - if (userFeedback) { - prompt += `### User Feedback -${userFeedback} - -`; - } - - // Add relevant excerpt from plan (just the task-related part to save context) - prompt += `### Reference: Full Plan -
-${planContent} -
- -## Instructions - -1. Focus ONLY on completing task ${task.id}: "${task.description}" -2. Do not work on other tasks -3. Use the existing codebase patterns -4. When done, summarize what you implemented - -Begin implementing task ${task.id} now.`; + // Use centralized template with variable substitution + let prompt = taskPromptTemplate; + prompt = prompt.replace(/\{\{taskId\}\}/g, task.id); + prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description); + prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || ''); + prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || ''); + prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr); + prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr); + prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr); + prompt = prompt.replace(/\{\{planContent\}\}/g, planContent); return prompt; } @@ -3461,31 +3481,39 @@ Begin implementing task ${task.id} now.`; for (const entry of entries) { if (entry.isDirectory()) { const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - const feature = JSON.parse(data) as Feature; - // Check if feature was interrupted (in_progress or pipeline_*) - if ( - feature.status === 'in_progress' || - (feature.status && feature.status.startsWith('pipeline_')) - ) { - // Verify it has existing context (agent-output.md) - const featureDir = getFeatureDir(projectPath, feature.id); - const contextPath = path.join(featureDir, 'agent-output.md'); - try { - await secureFs.access(contextPath); - interruptedFeatures.push(feature); - logger.info( - `Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` - ); - } catch { - // No context file, skip this feature - it will be restarted fresh - logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); - } + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${entry.name}`, logger); + + const feature = result.data; + if (!feature) { + // Skip features that couldn't be loaded or recovered + continue; + } + + // Check if feature was interrupted (in_progress or pipeline_*) + if ( + feature.status === 'in_progress' || + (feature.status && feature.status.startsWith('pipeline_')) + ) { + // Verify it has existing context (agent-output.md) + const featureDir = getFeatureDir(projectPath, feature.id); + const contextPath = path.join(featureDir, 'agent-output.md'); + try { + await secureFs.access(contextPath); + interruptedFeatures.push(feature); + logger.info( + `Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` + ); + } catch { + // No context file, skip this feature - it will be restarted fresh + logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); } - } catch { - // Skip invalid features } } } @@ -3553,32 +3581,13 @@ Begin implementing task ${task.id} now.`; // Limit output to avoid token limits const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput; - const userPrompt = `You are an Architecture Decision Record (ADR) extractor. Analyze this implementation and return ONLY JSON with learnings. No explanations. + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); -Feature: "${feature.title}" - -Implementation log: -${truncatedOutput} - -Extract MEANINGFUL learnings - not obvious things. For each, capture: -- DECISIONS: Why this approach vs alternatives? What would break if changed? -- GOTCHAS: What was unexpected? What's the root cause? How to avoid? -- PATTERNS: Why this pattern? What problem does it solve? Trade-offs? - -JSON format ONLY (no markdown, no text): -{"learnings": [{ - "category": "architecture|api|ui|database|auth|testing|performance|security|gotchas", - "type": "decision|gotcha|pattern", - "content": "What was done/learned", - "context": "Problem being solved or situation faced", - "why": "Reasoning - why this approach", - "rejected": "Alternative considered and why rejected", - "tradeoffs": "What became easier/harder", - "breaking": "What breaks if this is changed/removed" -}]} - -IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial patterns. -If nothing notable: {"learnings": []}`; + // Build user prompt using centralized template with variable substitution + let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate; + userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || ''); + userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput); try { // Get model from phase settings @@ -3612,8 +3621,7 @@ If nothing notable: {"learnings": []}`; cwd: projectPath, maxTurns: 1, allowedTools: [], - systemPrompt: - 'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.', + systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt, }); const responseText = result.text; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 35c00a20..9c2f8b78 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -22,6 +22,29 @@ export class ClaudeUsageService { private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; private isLinux = os.platform() === 'linux'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows + + /** + * Kill a PTY process with platform-specific handling. + * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. + * On Unix-like systems (macOS, Linux), we can specify the signal. + * + * @param ptyProcess - The PTY process to kill + * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') + */ + private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill(signal); + } + } /** * Kill a PTY process with platform-specific handling. @@ -197,30 +220,87 @@ export class ClaudeUsageService { ? ['/c', 'claude', '--add-dir', workingDirectory] : ['-c', `claude --add-dir "${workingDirectory}"`]; + // Using 'any' for ptyProcess because node-pty types don't include 'killed' property + // eslint-disable-next-line @typescript-eslint/no-explicit-any let ptyProcess: any = null; + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' + ); + } + try { - ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + ptyProcess = pty.spawn(shell, args, ptyOptions); } catch (spawnError) { const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); - logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); - // Return a user-friendly error instead of crashing - reject( - new Error( - `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` - ) - ); - return; + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn( + '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' + ); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, args, ptyOptions); + logger.info( + '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' + ); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error( + '[executeClaudeUsageCommandPty] Winpty fallback also failed:', + fallbackMessage + ); + reject( + new Error( + `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); + reject( + new Error( + `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } } const timeoutId = setTimeout(() => { @@ -260,12 +340,19 @@ export class ClaudeUsageService { const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); // Check for specific authentication/permission errors - if ( - cleanOutput.includes('OAuth token does not meet scope requirement') || - cleanOutput.includes('permission_error') || - cleanOutput.includes('token_expired') || - cleanOutput.includes('authentication_error') - ) { + // Must be very specific to avoid false positives from garbled terminal encoding + // Removed permission_error check as it was causing false positives with winpty encoding + const authChecks = { + oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), + tokenExpired: cleanOutput.includes('token_expired'), + // Only match if it looks like a JSON API error response + authError: + cleanOutput.includes('"type":"authentication_error"') || + cleanOutput.includes('"type": "authentication_error"'), + }; + const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; + + if (hasAuthError) { if (!settled) { settled = true; if (ptyProcess && !ptyProcess.killed) { @@ -281,11 +368,16 @@ export class ClaudeUsageService { } // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) - if ( - !hasSeenUsageData && - (cleanOutput.includes('Current session') || - (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) - ) { + // Also check for percentage patterns that appear in usage output + const hasUsageIndicators = + cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || + // Additional patterns for winpty - look for percentage patterns + /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || + cleanOutput.includes('Resets in') || + cleanOutput.includes('Current week'); + + if (!hasSeenUsageData && hasUsageIndicators) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -324,10 +416,18 @@ export class ClaudeUsageService { } // Detect REPL prompt and send /usage command - if ( - !hasSentCommand && - (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) - ) { + // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators + const isReplReady = + cleanOutput.includes('❯') || + cleanOutput.includes('? for shortcuts') || + // Fallback for winpty garbled encoding - detect CLI welcome screen elements + (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || + (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || + // Detect model indicator which appears when REPL is ready + (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || + (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); + + if (!hasSentCommand && isReplReady) { hasSentCommand = true; // Wait for REPL to fully settle setTimeout(() => { @@ -364,11 +464,9 @@ export class ClaudeUsageService { if (settled) return; settled = true; - if ( - output.includes('token_expired') || - output.includes('authentication_error') || - output.includes('permission_error') - ) { + // Check for auth errors - must be specific to avoid false positives + // Removed permission_error check as it was causing false positives with winpty encoding + if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 5187c0c8..49f5218c 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -379,10 +379,11 @@ class DevServerService { // Create server info early so we can reference it in handlers // We'll add it to runningServers after verifying the process started successfully + const hostname = process.env.HOSTNAME || 'localhost'; const serverInfo: DevServerInfo = { worktreePath, port, - url: `http://localhost:${port}`, + url: `http://${hostname}:${port}`, process: devProcess, startedAt: new Date(), scrollbackBuffer: '', @@ -474,7 +475,7 @@ class DevServerService { result: { worktreePath, port, - url: `http://localhost:${port}`, + url: `http://${hostname}:${port}`, message: `Dev server started on port ${port}`, }, }; @@ -594,6 +595,7 @@ class DevServerService { result?: { worktreePath: string; port: number; + url: string; logs: string; startedAt: string; }; @@ -613,6 +615,7 @@ class DevServerService { result: { worktreePath: server.worktreePath, port: server.port, + url: server.url, logs: server.scrollbackBuffer, startedAt: server.startedAt.toISOString(), }, diff --git a/apps/server/src/services/event-history-service.ts b/apps/server/src/services/event-history-service.ts new file mode 100644 index 00000000..b983af09 --- /dev/null +++ b/apps/server/src/services/event-history-service.ts @@ -0,0 +1,338 @@ +/** + * Event History Service - Stores and retrieves event records for debugging and replay + * + * Provides persistent storage for events in {projectPath}/.automaker/events/ + * Each event is stored as a separate JSON file with an index for quick listing. + * + * Features: + * - Store events when they occur + * - List and filter historical events + * - Replay events to test hook configurations + * - Delete old events to manage disk space + */ + +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getEventHistoryDir, + getEventHistoryIndexPath, + getEventPath, + ensureEventHistoryDir, +} from '@automaker/platform'; +import type { + StoredEvent, + StoredEventIndex, + StoredEventSummary, + EventHistoryFilter, + EventHookTrigger, +} from '@automaker/types'; +import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types'; +import { randomUUID } from 'crypto'; + +const logger = createLogger('EventHistoryService'); + +/** Maximum events to keep in the index (oldest are pruned) */ +const MAX_EVENTS_IN_INDEX = 1000; + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Input for storing a new event + */ +export interface StoreEventInput { + trigger: EventHookTrigger; + projectPath: string; + featureId?: string; + featureName?: string; + error?: string; + errorType?: string; + passes?: boolean; + metadata?: Record; +} + +/** + * EventHistoryService - Manages persistent storage of events + */ +export class EventHistoryService { + /** + * Store a new event to history + * + * @param input - Event data to store + * @returns Promise resolving to the stored event + */ + async storeEvent(input: StoreEventInput): Promise { + const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } = + input; + + // Ensure events directory exists + await ensureEventHistoryDir(projectPath); + + const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`; + const timestamp = new Date().toISOString(); + const projectName = this.extractProjectName(projectPath); + + const event: StoredEvent = { + id: eventId, + trigger, + timestamp, + projectPath, + projectName, + featureId, + featureName, + error, + errorType, + passes, + metadata, + }; + + // Write the full event to its own file + const eventPath = getEventPath(projectPath, eventId); + await atomicWriteJson(eventPath, event); + + // Update the index + await this.addToIndex(projectPath, event); + + logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`); + + return event; + } + + /** + * Get all events for a project with optional filtering + * + * @param projectPath - Absolute path to project directory + * @param filter - Optional filter criteria + * @returns Promise resolving to array of event summaries + */ + async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + let events = [...index.events]; + + // Apply filters + if (filter) { + if (filter.trigger) { + events = events.filter((e) => e.trigger === filter.trigger); + } + if (filter.featureId) { + events = events.filter((e) => e.featureId === filter.featureId); + } + if (filter.since) { + const sinceDate = new Date(filter.since).getTime(); + events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate); + } + if (filter.until) { + const untilDate = new Date(filter.until).getTime(); + events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate); + } + } + + // Sort by timestamp (newest first) + events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + // Apply pagination + if (filter?.offset) { + events = events.slice(filter.offset); + } + if (filter?.limit) { + events = events.slice(0, filter.limit); + } + + return events; + } + + /** + * Get a single event by ID + * + * @param projectPath - Absolute path to project directory + * @param eventId - Event identifier + * @returns Promise resolving to the full event or null if not found + */ + async getEvent(projectPath: string, eventId: string): Promise { + const eventPath = getEventPath(projectPath, eventId); + try { + const content = (await secureFs.readFile(eventPath, 'utf-8')) as string; + return JSON.parse(content) as StoredEvent; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Error reading event ${eventId}:`, error); + return null; + } + } + + /** + * Delete an event by ID + * + * @param projectPath - Absolute path to project directory + * @param eventId - Event identifier + * @returns Promise resolving to true if deleted + */ + async deleteEvent(projectPath: string, eventId: string): Promise { + // Remove from index + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const initialLength = index.events.length; + index.events = index.events.filter((e) => e.id !== eventId); + + if (index.events.length === initialLength) { + return false; // Event not found in index + } + + await atomicWriteJson(indexPath, index); + + // Delete the event file + const eventPath = getEventPath(projectPath, eventId); + try { + await secureFs.unlink(eventPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`Error deleting event file ${eventId}:`, error); + } + } + + logger.info(`Deleted event ${eventId}`); + return true; + } + + /** + * Clear all events for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of events cleared + */ + async clearEvents(projectPath: string): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const count = index.events.length; + + // Delete all event files + for (const event of index.events) { + const eventPath = getEventPath(projectPath, event.id); + try { + await secureFs.unlink(eventPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`Error deleting event file ${event.id}:`, error); + } + } + } + + // Reset the index + await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + logger.info(`Cleared ${count} events for project`); + return count; + } + + /** + * Get event count for a project + * + * @param projectPath - Absolute path to project directory + * @param filter - Optional filter criteria + * @returns Promise resolving to event count + */ + async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise { + const events = await this.getEvents(projectPath, { + ...filter, + limit: undefined, + offset: undefined, + }); + return events.length; + } + + /** + * Add an event to the index (internal) + */ + private async addToIndex(projectPath: string, event: StoredEvent): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const summary: StoredEventSummary = { + id: event.id, + trigger: event.trigger, + timestamp: event.timestamp, + featureName: event.featureName, + featureId: event.featureId, + }; + + // Add to beginning (newest first) + index.events.unshift(summary); + + // Prune old events if over limit + if (index.events.length > MAX_EVENTS_IN_INDEX) { + const removed = index.events.splice(MAX_EVENTS_IN_INDEX); + // Delete the pruned event files + for (const oldEvent of removed) { + const eventPath = getEventPath(projectPath, oldEvent.id); + try { + await secureFs.unlink(eventPath); + } catch { + // Ignore deletion errors for pruned events + } + } + logger.info(`Pruned ${removed.length} old events from history`); + } + + await atomicWriteJson(indexPath, index); + } + + /** + * Extract project name from path + */ + private extractProjectName(projectPath: string): string { + const parts = projectPath.split(/[/\\]/); + return parts[parts.length - 1] || projectPath; + } +} + +// Singleton instance +let eventHistoryServiceInstance: EventHistoryService | null = null; + +/** + * Get the singleton event history service instance + */ +export function getEventHistoryService(): EventHistoryService { + if (!eventHistoryServiceInstance) { + eventHistoryServiceInstance = new EventHistoryService(); + } + return eventHistoryServiceInstance; +} diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts new file mode 100644 index 00000000..08da71dd --- /dev/null +++ b/apps/server/src/services/event-hook-service.ts @@ -0,0 +1,373 @@ +/** + * Event Hook Service - Executes custom actions when system events occur + * + * Listens to the event emitter and triggers configured hooks: + * - Shell commands: Executed with configurable timeout + * - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution + * + * Also stores events to history for debugging and replay. + * + * Supported events: + * - feature_created: A new feature was created + * - feature_success: Feature completed successfully + * - feature_error: Feature failed with an error + * - auto_mode_complete: Auto mode finished all features (idle state) + * - auto_mode_error: Auto mode encountered a critical error + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; +import type { EventHistoryService } from './event-history-service.js'; +import type { + EventHook, + EventHookTrigger, + EventHookShellAction, + EventHookHttpAction, +} from '@automaker/types'; + +const execAsync = promisify(exec); +const logger = createLogger('EventHooks'); + +/** Default timeout for shell commands (30 seconds) */ +const DEFAULT_SHELL_TIMEOUT = 30000; + +/** Default timeout for HTTP requests (10 seconds) */ +const DEFAULT_HTTP_TIMEOUT = 10000; + +/** + * Context available for variable substitution in hooks + */ +interface HookContext { + featureId?: string; + featureName?: string; + projectPath?: string; + projectName?: string; + error?: string; + errorType?: string; + timestamp: string; + eventType: EventHookTrigger; +} + +/** + * Auto-mode event payload structure + */ +interface AutoModeEventPayload { + type?: string; + featureId?: string; + passes?: boolean; + message?: string; + error?: string; + errorType?: string; + projectPath?: string; +} + +/** + * Feature created event payload structure + */ +interface FeatureCreatedPayload { + featureId: string; + featureName?: string; + projectPath: string; +} + +/** + * Event Hook Service + * + * Manages execution of user-configured event hooks in response to system events. + * Also stores events to history for debugging and replay. + */ +export class EventHookService { + private emitter: EventEmitter | null = null; + private settingsService: SettingsService | null = null; + private eventHistoryService: EventHistoryService | null = null; + private unsubscribe: (() => void) | null = null; + + /** + * Initialize the service with event emitter, settings service, and event history service + */ + initialize( + emitter: EventEmitter, + settingsService: SettingsService, + eventHistoryService?: EventHistoryService + ): void { + this.emitter = emitter; + this.settingsService = settingsService; + this.eventHistoryService = eventHistoryService || null; + + // Subscribe to events + this.unsubscribe = emitter.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEvent(payload as AutoModeEventPayload); + } else if (type === 'feature:created') { + this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload); + } + }); + + logger.info('Event hook service initialized'); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + this.emitter = null; + this.settingsService = null; + this.eventHistoryService = null; + } + + /** + * Handle auto-mode events and trigger matching hooks + */ + private async handleAutoModeEvent(payload: AutoModeEventPayload): Promise { + if (!payload.type) return; + + // Map internal event types to hook triggers + let trigger: EventHookTrigger | null = null; + + switch (payload.type) { + case 'auto_mode_feature_complete': + trigger = payload.passes ? 'feature_success' : 'feature_error'; + break; + case 'auto_mode_error': + // Feature-level error (has featureId) vs auto-mode level error + trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; + break; + case 'auto_mode_idle': + trigger = 'auto_mode_complete'; + break; + default: + // Other event types don't trigger hooks + return; + } + + if (!trigger) return; + + // Build context for variable substitution + const context: HookContext = { + featureId: payload.featureId, + projectPath: payload.projectPath, + projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, + error: payload.error || payload.message, + errorType: payload.errorType, + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + // Execute matching hooks (pass passes for feature completion events) + await this.executeHooksForTrigger(trigger, context, { passes: payload.passes }); + } + + /** + * Handle feature:created events and trigger matching hooks + */ + private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise { + const context: HookContext = { + featureId: payload.featureId, + featureName: payload.featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + timestamp: new Date().toISOString(), + eventType: 'feature_created', + }; + + await this.executeHooksForTrigger('feature_created', context); + } + + /** + * Execute all enabled hooks matching the given trigger and store event to history + */ + private async executeHooksForTrigger( + trigger: EventHookTrigger, + context: HookContext, + additionalData?: { passes?: boolean } + ): Promise { + // Store event to history (even if no hooks match) + if (this.eventHistoryService && context.projectPath) { + try { + await this.eventHistoryService.storeEvent({ + trigger, + projectPath: context.projectPath, + featureId: context.featureId, + featureName: context.featureName, + error: context.error, + errorType: context.errorType, + passes: additionalData?.passes, + }); + } catch (error) { + logger.error('Failed to store event to history:', error); + } + } + + if (!this.settingsService) { + logger.warn('Settings service not available'); + return; + } + + try { + const settings = await this.settingsService.getGlobalSettings(); + const hooks = settings.eventHooks || []; + + // Filter to enabled hooks matching this trigger + const matchingHooks = hooks.filter((hook) => hook.enabled && hook.trigger === trigger); + + if (matchingHooks.length === 0) { + return; + } + + logger.info(`Executing ${matchingHooks.length} hook(s) for trigger: ${trigger}`); + + // Execute hooks in parallel (don't wait for one to finish before starting next) + await Promise.allSettled(matchingHooks.map((hook) => this.executeHook(hook, context))); + } catch (error) { + logger.error('Error executing hooks:', error); + } + } + + /** + * Execute a single hook + */ + private async executeHook(hook: EventHook, context: HookContext): Promise { + const hookName = hook.name || hook.id; + + try { + if (hook.action.type === 'shell') { + await this.executeShellHook(hook.action, context, hookName); + } else if (hook.action.type === 'http') { + await this.executeHttpHook(hook.action, context, hookName); + } + } catch (error) { + logger.error(`Hook "${hookName}" failed:`, error); + } + } + + /** + * Execute a shell command hook + */ + private async executeShellHook( + action: EventHookShellAction, + context: HookContext, + hookName: string + ): Promise { + const command = this.substituteVariables(action.command, context); + const timeout = action.timeout || DEFAULT_SHELL_TIMEOUT; + + logger.info(`Executing shell hook "${hookName}": ${command}`); + + try { + const { stdout, stderr } = await execAsync(command, { + timeout, + maxBuffer: 1024 * 1024, // 1MB buffer + }); + + if (stdout) { + logger.debug(`Hook "${hookName}" stdout: ${stdout.trim()}`); + } + if (stderr) { + logger.warn(`Hook "${hookName}" stderr: ${stderr.trim()}`); + } + + logger.info(`Shell hook "${hookName}" completed successfully`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { + logger.error(`Shell hook "${hookName}" timed out after ${timeout}ms`); + } + throw error; + } + } + + /** + * Execute an HTTP webhook hook + */ + private async executeHttpHook( + action: EventHookHttpAction, + context: HookContext, + hookName: string + ): Promise { + const url = this.substituteVariables(action.url, context); + const method = action.method || 'POST'; + + // Substitute variables in headers + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (action.headers) { + for (const [key, value] of Object.entries(action.headers)) { + headers[key] = this.substituteVariables(value, context); + } + } + + // Substitute variables in body + let body: string | undefined; + if (action.body) { + body = this.substituteVariables(action.body, context); + } else if (method !== 'GET') { + // Default body with context information + body = JSON.stringify({ + eventType: context.eventType, + timestamp: context.timestamp, + featureId: context.featureId, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + }); + } + + logger.info(`Executing HTTP hook "${hookName}": ${method} ${url}`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT); + + const response = await fetch(url, { + method, + headers, + body: method !== 'GET' ? body : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn(`HTTP hook "${hookName}" received status ${response.status}`); + } else { + logger.info(`HTTP hook "${hookName}" completed successfully (status: ${response.status})`); + } + } catch (error) { + if ((error as Error).name === 'AbortError') { + logger.error(`HTTP hook "${hookName}" timed out after ${DEFAULT_HTTP_TIMEOUT}ms`); + } + throw error; + } + } + + /** + * Substitute {{variable}} placeholders in a string + */ + private substituteVariables(template: string, context: HookContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof HookContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + } + + /** + * Extract project name from path + */ + private extractProjectName(projectPath: string): string { + const parts = projectPath.split(/[/\\]/); + return parts[parts.length - 1] || projectPath; + } +} + +// Singleton instance +export const eventHookService = new EventHookService(); diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 409abd2a..b40a85f0 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -5,14 +5,22 @@ import path from 'path'; import type { Feature, DescriptionHistoryEntry } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; +import { + createLogger, + atomicWriteJson, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, +} from '@automaker/utils'; import * as secureFs from '../lib/secure-fs.js'; import { getFeaturesDir, getFeatureDir, getFeatureImagesDir, + getAppSpecPath, ensureAutomakerDir, } from '@automaker/platform'; +import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js'; const logger = createLogger('FeatureLoader'); @@ -192,31 +200,31 @@ export class FeatureLoader { })) as any[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); - // Load all features concurrently (secureFs has built-in concurrency limiting) + // Load all features concurrently with automatic recovery from backups const featurePromises = featureDirs.map(async (dir) => { const featureId = dir.name; const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); - try { - const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; - const feature = JSON.parse(content); + // Use recovery-enabled read to handle corrupted files + const result = await readJsonWithRecovery(featureJsonPath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); - if (!feature.id) { - logger.warn(`Feature ${featureId} missing required 'id' field, skipping`); - return null; - } + logRecoveryWarning(result, `Feature ${featureId}`, logger); - return feature as Feature; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } else if (error instanceof SyntaxError) { - logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`); - } else { - logger.error(`Failed to load feature ${featureId}:`, (error as Error).message); - } + const feature = result.data; + + if (!feature) { return null; } + + if (!feature.id) { + logger.warn(`Feature ${featureId} missing required 'id' field, skipping`); + return null; + } + + return feature; }); const results = await Promise.all(featurePromises); @@ -236,21 +244,85 @@ export class FeatureLoader { } } + /** + * Normalize a title for comparison (case-insensitive, trimmed) + */ + private normalizeTitle(title: string): string { + return title.toLowerCase().trim(); + } + + /** + * Find a feature by its title (case-insensitive match) + * @param projectPath - Path to the project + * @param title - Title to search for + * @returns The matching feature or null if not found + */ + async findByTitle(projectPath: string, title: string): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + + /** + * Check if a title already exists on another feature (for duplicate detection) + * @param projectPath - Path to the project + * @param title - Title to check + * @param excludeFeatureId - Optional feature ID to exclude from the check (for updates) + * @returns The duplicate feature if found, null otherwise + */ + async findDuplicateTitle( + projectPath: string, + title: string, + excludeFeatureId?: string + ): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + // Skip the feature being updated (if provided) + if (excludeFeatureId && feature.id === excludeFeatureId) { + continue; + } + + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + /** * Get a single feature by ID + * Uses automatic recovery from backups if the main file is corrupted */ async get(projectPath: string, featureId: string): Promise { - try { - const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); - const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; - return JSON.parse(content); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - logger.error(`Failed to get feature ${featureId}:`, error); - throw error; - } + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + // Use recovery-enabled read to handle corrupted files + const result = await readJsonWithRecovery(featureJsonPath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + return result.data; } /** @@ -294,8 +366,8 @@ export class FeatureLoader { descriptionHistory: initialHistory, }; - // Write feature.json - await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8'); + // Write feature.json atomically with backup support + await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); logger.info(`Created feature ${featureId}`); return feature; @@ -379,9 +451,9 @@ export class FeatureLoader { descriptionHistory: updatedHistory, }; - // Write back to file + // Write back to file atomically with backup support const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); - await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8'); + await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT }); logger.info(`Updated feature ${featureId}`); return updatedFeature; @@ -460,4 +532,64 @@ export class FeatureLoader { } } } + + /** + * Sync a completed feature to the app_spec.txt implemented_features section + * + * When a feature is completed, this method adds it to the implemented_features + * section of the project's app_spec.txt file. This keeps the spec in sync + * with the actual state of the codebase. + * + * @param projectPath - Path to the project + * @param feature - The feature to sync (must have title or description) + * @param fileLocations - Optional array of file paths where the feature was implemented + * @returns True if the spec was updated, false if no spec exists or feature was skipped + */ + async syncFeatureToAppSpec( + projectPath: string, + feature: Feature, + fileLocations?: string[] + ): Promise { + try { + const appSpecPath = getAppSpecPath(projectPath); + + // Read the current app_spec.txt + let specContent: string; + try { + specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`); + return false; + } + throw error; + } + + // Build the implemented feature entry + const featureName = feature.title || `Feature: ${feature.id}`; + const implementedFeature: ImplementedFeature = { + name: featureName, + description: feature.description, + ...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}), + }; + + // Add the feature to the implemented_features section + const updatedSpecContent = addImplementedFeature(specContent, implementedFeature); + + // Check if the content actually changed (feature might already exist) + if (updatedSpecContent === specContent) { + logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`); + return false; + } + + // Write the updated spec back to the file + await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8'); + + logger.info(`Synced feature "${featureName}" to app_spec.txt`); + return true; + } catch (error) { + logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error); + throw error; + } + } } diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 81fc3de6..4ef3d8a8 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -41,6 +41,7 @@ import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { resolveModelString } from '@automaker/model-resolver'; import { stripProviderPrefix } from '@automaker/types'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; const logger = createLogger('IdeationService'); @@ -195,8 +196,12 @@ export class IdeationService { // Gather existing features and ideas to prevent duplicate suggestions const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); + // Build system prompt for ideation const systemPrompt = this.buildIdeationSystemPrompt( + prompts.ideation.ideationSystemPrompt, contextResult.formattedPrompt, activeSession.session.promptCategory, existingWorkContext @@ -645,8 +650,12 @@ export class IdeationService { // Gather existing features and ideas to prevent duplicates const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); + // Build system prompt for structured suggestions const systemPrompt = this.buildSuggestionsSystemPrompt( + prompts.ideation.suggestionsSystemPrompt, contextPrompt, category, count, @@ -721,8 +730,14 @@ export class IdeationService { /** * Build system prompt for structured suggestion generation + * @param basePrompt - The base system prompt from settings + * @param contextFilesPrompt - Project context from loaded files + * @param category - The idea category to focus on + * @param count - Number of suggestions to generate + * @param existingWorkContext - Context about existing features/ideas */ private buildSuggestionsSystemPrompt( + basePrompt: string, contextFilesPrompt: string | undefined, category: IdeaCategory, count: number = 10, @@ -734,35 +749,18 @@ export class IdeationService { const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; - return `You are an AI product strategist helping brainstorm feature ideas for a software project. + // Replace placeholder {{count}} if present, otherwise append count instruction + let prompt = basePrompt; + if (prompt.includes('{{count}}')) { + prompt = prompt.replace(/\{\{count\}\}/g, String(count)); + } else { + prompt += `\n\nGenerate exactly ${count} suggestions.`; + } -IMPORTANT: You do NOT have access to any tools. You CANNOT read files, search code, or run commands. -You must generate suggestions based ONLY on the project context provided below. -Do NOT say "I'll analyze" or "Let me explore" - you cannot do those things. - -Based on the project context and the user's prompt, generate exactly ${count} creative and actionable feature suggestions. - -YOUR RESPONSE MUST BE ONLY A JSON ARRAY - nothing else. No explanation, no preamble, no markdown code fences. - -Each suggestion must have this structure: -{ - "title": "Short, actionable title (max 60 chars)", - "description": "Clear description of what to build or improve (2-3 sentences)", - "rationale": "Why this is valuable - the problem it solves or opportunity it creates", - "priority": "high" | "medium" | "low" -} + return `${prompt} Focus area: ${this.getCategoryDescription(category)} -Guidelines: -- Generate exactly ${count} suggestions -- Be specific and actionable - avoid vague ideas -- Mix different priority levels (some high, some medium, some low) -- Each suggestion should be independently implementable -- Think creatively - include both obvious improvements and innovative ideas -- Consider the project's domain and target users -- IMPORTANT: Do NOT suggest features or ideas that already exist in the "Existing Features" or "Existing Ideas" sections below - ${contextSection}${existingWorkSection}`; } @@ -1269,30 +1267,11 @@ ${contextSection}${existingWorkSection}`; // ============================================================================ private buildIdeationSystemPrompt( + basePrompt: string, contextFilesPrompt: string | undefined, category?: IdeaCategory, existingWorkContext?: string ): string { - const basePrompt = `You are an AI product strategist and UX expert helping brainstorm ideas for improving a software project. - -Your role is to: -- Analyze the codebase structure and patterns -- Identify opportunities for improvement -- Suggest actionable ideas with clear rationale -- Consider user experience, technical feasibility, and business value -- Be specific and reference actual files/components when possible - -When suggesting ideas: -1. Provide a clear, concise title -2. Explain the problem or opportunity -3. Describe the proposed solution -4. Highlight the expected benefit -5. Note any dependencies or considerations - -IMPORTANT: Do NOT suggest features or ideas that already exist in the project. Check the "Existing Features" and "Existing Ideas" sections below to avoid duplicates. - -Focus on practical, implementable suggestions that would genuinely improve the product.`; - const categoryContext = category ? `\n\nFocus area: ${this.getCategoryDescription(category)}` : ''; diff --git a/apps/server/src/services/notification-service.ts b/apps/server/src/services/notification-service.ts new file mode 100644 index 00000000..21685308 --- /dev/null +++ b/apps/server/src/services/notification-service.ts @@ -0,0 +1,280 @@ +/** + * Notification Service - Handles reading/writing notifications to JSON files + * + * Provides persistent storage for project-level notifications in + * {projectPath}/.automaker/notifications.json + * + * Notifications alert users when: + * - Features reach specific statuses (waiting_approval, verified) + * - Long-running operations complete (spec generation) + */ + +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform'; +import type { Notification, NotificationsFile, NotificationType } from '@automaker/types'; +import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types'; +import type { EventEmitter } from '../lib/events.js'; +import { randomUUID } from 'crypto'; + +const logger = createLogger('NotificationService'); + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + // Clean up temp file if it exists + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Input for creating a new notification + */ +export interface CreateNotificationInput { + type: NotificationType; + title: string; + message: string; + featureId?: string; + projectPath: string; +} + +/** + * NotificationService - Manages persistent storage of notifications + * + * Handles reading and writing notifications to JSON files with atomic operations + * for reliability. Each project has its own notifications.json file. + */ +export class NotificationService { + private events: EventEmitter | null = null; + + /** + * Set the event emitter for broadcasting notification events + */ + setEventEmitter(events: EventEmitter): void { + this.events = events; + } + + /** + * Get all notifications for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to array of notifications + */ + async getNotifications(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + // Filter out dismissed notifications and sort by date (newest first) + return file.notifications + .filter((n) => !n.dismissed) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + /** + * Get unread notification count for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to unread count + */ + async getUnreadCount(projectPath: string): Promise { + const notifications = await this.getNotifications(projectPath); + return notifications.filter((n) => !n.read).length; + } + + /** + * Create a new notification + * + * @param input - Notification creation input + * @returns Promise resolving to the created notification + */ + async createNotification(input: CreateNotificationInput): Promise { + const { projectPath, type, title, message, featureId } = input; + + // Ensure automaker directory exists + await ensureAutomakerDir(projectPath); + + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification: Notification = { + id: randomUUID(), + type, + title, + message, + createdAt: new Date().toISOString(), + read: false, + dismissed: false, + featureId, + projectPath, + }; + + file.notifications.push(notification); + await atomicWriteJson(notificationsPath, file); + + logger.info(`Created notification: ${title} for project ${projectPath}`); + + // Emit event for real-time updates + if (this.events) { + this.events.emit('notification:created', notification); + } + + return notification; + } + + /** + * Mark a notification as read + * + * @param projectPath - Absolute path to project directory + * @param notificationId - ID of the notification to mark as read + * @returns Promise resolving to the updated notification or null if not found + */ + async markAsRead(projectPath: string, notificationId: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification = file.notifications.find((n) => n.id === notificationId); + if (!notification) { + return null; + } + + notification.read = true; + await atomicWriteJson(notificationsPath, file); + + logger.info(`Marked notification ${notificationId} as read`); + return notification; + } + + /** + * Mark all notifications as read for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of notifications marked as read + */ + async markAllAsRead(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + let count = 0; + for (const notification of file.notifications) { + if (!notification.read && !notification.dismissed) { + notification.read = true; + count++; + } + } + + if (count > 0) { + await atomicWriteJson(notificationsPath, file); + logger.info(`Marked ${count} notifications as read`); + } + + return count; + } + + /** + * Dismiss a notification + * + * @param projectPath - Absolute path to project directory + * @param notificationId - ID of the notification to dismiss + * @returns Promise resolving to true if notification was dismissed + */ + async dismissNotification(projectPath: string, notificationId: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification = file.notifications.find((n) => n.id === notificationId); + if (!notification) { + return false; + } + + notification.dismissed = true; + await atomicWriteJson(notificationsPath, file); + + logger.info(`Dismissed notification ${notificationId}`); + return true; + } + + /** + * Dismiss all notifications for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of notifications dismissed + */ + async dismissAll(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + let count = 0; + for (const notification of file.notifications) { + if (!notification.dismissed) { + notification.dismissed = true; + count++; + } + } + + if (count > 0) { + await atomicWriteJson(notificationsPath, file); + logger.info(`Dismissed ${count} notifications`); + } + + return count; + } +} + +// Singleton instance +let notificationServiceInstance: NotificationService | null = null; + +/** + * Get the singleton notification service instance + */ +export function getNotificationService(): NotificationService { + if (!notificationServiceInstance) { + notificationServiceInstance = new NotificationService(); + } + return notificationServiceInstance; +} diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5f57ad83..e63b075c 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -7,7 +7,7 @@ * - Per-project settings ({projectPath}/.automaker/settings.json) */ -import { createLogger } from '@automaker/utils'; +import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils'; import * as secureFs from '../lib/secure-fs.js'; import { @@ -42,28 +42,8 @@ import { const logger = createLogger('SettingsService'); /** - * Atomic file write - write to temp file then rename - */ -async function atomicWriteJson(filePath: string, data: unknown): Promise { - const tempPath = `${filePath}.tmp.${Date.now()}`; - const content = JSON.stringify(data, null, 2); - - try { - await secureFs.writeFile(tempPath, content, 'utf-8'); - await secureFs.rename(tempPath, filePath); - } catch (error) { - // Clean up temp file if it exists - try { - await secureFs.unlink(tempPath); - } catch { - // Ignore cleanup errors - } - throw error; - } -} - -/** - * Safely read JSON file with fallback to default + * Wrapper for readJsonFile from utils that uses the local secureFs + * to maintain compatibility with the server's secure file system */ async function readJsonFile(filePath: string, defaultValue: T): Promise { try { @@ -90,6 +70,13 @@ async function fileExists(filePath: string): Promise { } } +/** + * Write settings atomically with backup support + */ +async function writeSettingsJson(filePath: string, data: unknown): Promise { + await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT }); +} + /** * SettingsService - Manages persistent storage of user settings and credentials * @@ -180,7 +167,7 @@ export class SettingsService { if (needsSave) { try { await ensureDataDir(this.dataDir); - await atomicWriteJson(settingsPath, result); + await writeSettingsJson(settingsPath, result); logger.info('Settings migration complete'); } catch (error) { logger.error('Failed to save migrated settings:', error); @@ -340,7 +327,7 @@ export class SettingsService { }; } - await atomicWriteJson(settingsPath, updated); + await writeSettingsJson(settingsPath, updated); logger.info('Global settings updated'); return updated; @@ -414,7 +401,7 @@ export class SettingsService { }; } - await atomicWriteJson(credentialsPath, updated); + await writeSettingsJson(credentialsPath, updated); logger.info('Credentials updated'); return updated; @@ -525,7 +512,7 @@ export class SettingsService { }; } - await atomicWriteJson(settingsPath, updated); + await writeSettingsJson(settingsPath, updated); logger.info(`Project settings updated for ${projectPath}`); return updated; diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index bd4481a8..f83aaede 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -71,6 +71,12 @@ export class TerminalService extends EventEmitter { private dataCallbacks: Set = new Set(); private exitCallbacks: Set = new Set(); private isWindows = os.platform() === 'win32'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows /** * Kill a PTY process with platform-specific handling. @@ -339,13 +345,60 @@ export class TerminalService extends EventEmitter { logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); - const ptyProcess = pty.spawn(shell, shellArgs, { + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { name: 'xterm-256color', cols: options.cols || 80, rows: options.rows || 24, cwd, env, - }); + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + `[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)` + ); + } + + let ptyProcess: pty.IPty; + try { + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + } catch (spawnError) { + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed:`, errorMessage); + return null; + } + } const session: TerminalSession = { id, @@ -409,7 +462,11 @@ export class TerminalService extends EventEmitter { // Handle exit ptyProcess.onExit(({ exitCode }) => { - logger.info(`Session ${id} exited with code ${exitCode}`); + const exitMessage = + exitCode === undefined || exitCode === null + ? 'Session terminated' + : `Session exited with code ${exitCode}`; + logger.info(`${exitMessage} (${id})`); this.sessions.delete(id); this.exitCallbacks.forEach((cb) => cb(id, exitCode)); this.emit('exit', id, exitCode); diff --git a/apps/server/tests/unit/lib/xml-extractor.test.ts b/apps/server/tests/unit/lib/xml-extractor.test.ts new file mode 100644 index 00000000..750a5f33 --- /dev/null +++ b/apps/server/tests/unit/lib/xml-extractor.test.ts @@ -0,0 +1,1027 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + escapeXml, + unescapeXml, + extractXmlSection, + extractXmlElements, + extractImplementedFeatures, + extractImplementedFeatureNames, + featureToXml, + featuresToXml, + updateImplementedFeaturesSection, + addImplementedFeature, + removeImplementedFeature, + updateImplementedFeature, + hasImplementedFeature, + toSpecOutputFeatures, + fromSpecOutputFeatures, + type ImplementedFeature, + type XmlExtractorLogger, +} from '@/lib/xml-extractor.js'; + +describe('xml-extractor.ts', () => { + // Mock logger for testing custom logger functionality + const createMockLogger = (): XmlExtractorLogger & { calls: string[] } => { + const calls: string[] = []; + return { + calls, + debug: vi.fn((msg: string) => calls.push(`debug: ${msg}`)), + warn: vi.fn((msg: string) => calls.push(`warn: ${msg}`)), + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('escapeXml', () => { + it('should escape ampersand', () => { + expect(escapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should escape less than', () => { + expect(escapeXml('a < b')).toBe('a < b'); + }); + + it('should escape greater than', () => { + expect(escapeXml('a > b')).toBe('a > b'); + }); + + it('should escape double quotes', () => { + expect(escapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should escape single quotes', () => { + expect(escapeXml("it's" + ' fine')).toBe('it's fine'); + }); + + it('should handle null', () => { + expect(escapeXml(null)).toBe(''); + }); + + it('should handle undefined', () => { + expect(escapeXml(undefined)).toBe(''); + }); + + it('should handle empty string', () => { + expect(escapeXml('')).toBe(''); + }); + + it('should escape multiple special characters', () => { + expect(escapeXml('a < b & c > d "e" \'f\'')).toBe( + 'a < b & c > d "e" 'f'' + ); + }); + }); + + describe('unescapeXml', () => { + it('should unescape ampersand', () => { + expect(unescapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should unescape less than', () => { + expect(unescapeXml('a < b')).toBe('a < b'); + }); + + it('should unescape greater than', () => { + expect(unescapeXml('a > b')).toBe('a > b'); + }); + + it('should unescape double quotes', () => { + expect(unescapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should unescape single quotes', () => { + expect(unescapeXml('it's fine')).toBe("it's fine"); + }); + + it('should handle empty string', () => { + expect(unescapeXml('')).toBe(''); + }); + + it('should roundtrip with escapeXml', () => { + const original = 'Test & "quoted" \'apostrophe\''; + expect(unescapeXml(escapeXml(original))).toBe(original); + }); + }); + + describe('extractXmlSection', () => { + it('should extract section content', () => { + const xml = '
content here
'; + expect(extractXmlSection(xml, 'section')).toBe('content here'); + }); + + it('should extract multiline section content', () => { + const xml = ` +
+ line 1 + line 2 +
+
`; + expect(extractXmlSection(xml, 'section')).toContain('line 1'); + expect(extractXmlSection(xml, 'section')).toContain('line 2'); + }); + + it('should return null for non-existent section', () => { + const xml = 'content'; + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should be case-insensitive', () => { + const xml = '
content
'; + expect(extractXmlSection(xml, 'section')).toBe('content'); + }); + + it('should handle empty section', () => { + const xml = '
'; + expect(extractXmlSection(xml, 'section')).toBe(''); + }); + }); + + describe('extractXmlElements', () => { + it('should extract all element values', () => { + const xml = 'onetwothree'; + expect(extractXmlElements(xml, 'item')).toEqual(['one', 'two', 'three']); + }); + + it('should return empty array for non-existent elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual([]); + }); + + it('should trim whitespace', () => { + const xml = ' spaced '; + expect(extractXmlElements(xml, 'item')).toEqual(['spaced']); + }); + + it('should unescape XML entities', () => { + const xml = 'foo & bar'; + expect(extractXmlElements(xml, 'item')).toEqual(['foo & bar']); + }); + + it('should handle empty elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual(['', 'value']); + }); + }); + + describe('extractImplementedFeatures', () => { + const sampleSpec = ` + + Test Project + + + Feature One + First feature description + + + Feature Two + Second feature description + + src/feature-two.ts + src/utils/helper.ts + + + +`; + + it('should extract all features', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features).toHaveLength(2); + }); + + it('should extract feature names', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].name).toBe('Feature One'); + expect(features[1].name).toBe('Feature Two'); + }); + + it('should extract feature descriptions', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].description).toBe('First feature description'); + expect(features[1].description).toBe('Second feature description'); + }); + + it('should extract file_locations when present', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].file_locations).toBeUndefined(); + expect(features[1].file_locations).toEqual(['src/feature-two.ts', 'src/utils/helper.ts']); + }); + + it('should return empty array for missing section', () => { + const xml = + 'Test'; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should return empty array for empty section', () => { + const xml = ` + + + `; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should handle escaped content', () => { + const xml = ` + + Test & Feature + Uses <brackets> + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses '); + }); + }); + + describe('extractImplementedFeatureNames', () => { + it('should return only feature names', () => { + const xml = ` + + Feature A + Description A + + + Feature B + Description B + + `; + expect(extractImplementedFeatureNames(xml)).toEqual(['Feature A', 'Feature B']); + }); + + it('should return empty array for no features', () => { + const xml = ''; + expect(extractImplementedFeatureNames(xml)).toEqual([]); + }); + }); + + describe('featureToXml', () => { + it('should generate XML for feature without file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + }; + const xml = featureToXml(feature); + expect(xml).toContain('My Feature'); + expect(xml).toContain('Feature description'); + expect(xml).not.toContain(''); + }); + + it('should generate XML for feature with file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + file_locations: ['src/index.ts', 'src/utils.ts'], + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + expect(xml).toContain('src/index.ts'); + expect(xml).toContain('src/utils.ts'); + }); + + it('should escape special characters', () => { + const feature: ImplementedFeature = { + name: 'Test & Feature', + description: 'Has ', + }; + const xml = featureToXml(feature); + expect(xml).toContain('Test & Feature'); + expect(xml).toContain('Has <tags>'); + }); + + it('should not include empty file_locations array', () => { + const feature: ImplementedFeature = { + name: 'Feature', + description: 'Desc', + file_locations: [], + }; + const xml = featureToXml(feature); + expect(xml).not.toContain(''); + }); + }); + + describe('featuresToXml', () => { + it('should generate XML for multiple features', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2' }, + ]; + const xml = featuresToXml(features); + expect(xml).toContain('Feature 1'); + expect(xml).toContain('Feature 2'); + }); + + it('should handle empty array', () => { + expect(featuresToXml([])).toBe(''); + }); + }); + + describe('updateImplementedFeaturesSection', () => { + const baseSpec = ` + + Test + + Testing + + + + Old Feature + Old description + + +`; + + it('should replace existing section', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('New Feature'); + expect(result).not.toContain('Old Feature'); + }); + + it('should insert section after core_capabilities if missing', () => { + const specWithoutSection = ` + + Test + + Testing + +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutSection, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + }); + + it('should handle multiple features', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'Feature A', description: 'Desc A' }, + { name: 'Feature B', description: 'Desc B', file_locations: ['src/b.ts'] }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + expect(result).toContain('src/b.ts'); + }); + }); + + describe('addImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Existing description + + `; + + it('should add new feature', () => { + const newFeature: ImplementedFeature = { + name: 'New Feature', + description: 'New description', + }; + const result = addImplementedFeature(baseSpec, newFeature); + expect(result).toContain('Existing Feature'); + expect(result).toContain('New Feature'); + }); + + it('should not add duplicate feature', () => { + const duplicate: ImplementedFeature = { + name: 'Existing Feature', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + // Should still have only one instance + const matches = result.match(/Existing Feature/g); + expect(matches).toHaveLength(1); + }); + + it('should be case-insensitive for duplicates', () => { + const duplicate: ImplementedFeature = { + name: 'EXISTING FEATURE', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + expect(result).not.toContain('EXISTING FEATURE'); + }); + }); + + describe('removeImplementedFeature', () => { + const baseSpec = ` + + Feature A + Description A + + + Feature B + Description B + + `; + + it('should remove feature by name', () => { + const result = removeImplementedFeature(baseSpec, 'Feature A'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should be case-insensitive', () => { + const result = removeImplementedFeature(baseSpec, 'feature a'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should return unchanged content if feature not found', () => { + const result = removeImplementedFeature(baseSpec, 'Nonexistent'); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + }); + + describe('updateImplementedFeature', () => { + const baseSpec = ` + + My Feature + Original description + + `; + + it('should update feature description', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'Updated description', + }); + expect(result).toContain('Updated description'); + expect(result).not.toContain('Original description'); + }); + + it('should add file_locations', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + file_locations: ['src/new.ts'], + }); + expect(result).toContain(''); + expect(result).toContain('src/new.ts'); + }); + + it('should preserve feature name if not updated', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'New desc', + }); + expect(result).toContain('My Feature'); + }); + + it('should be case-insensitive', () => { + const result = updateImplementedFeature(baseSpec, 'my feature', { + description: 'Updated', + }); + expect(result).toContain('Updated'); + }); + + it('should return unchanged content if feature not found', () => { + const result = updateImplementedFeature(baseSpec, 'Nonexistent', { + description: 'New', + }); + expect(result).toContain('Original description'); + }); + }); + + describe('hasImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Description + + `; + + it('should return true for existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Existing Feature')).toBe(true); + }); + + it('should return false for non-existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Nonexistent')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(hasImplementedFeature(baseSpec, 'existing feature')).toBe(true); + expect(hasImplementedFeature(baseSpec, 'EXISTING FEATURE')).toBe(true); + }); + }); + + describe('toSpecOutputFeatures', () => { + it('should convert to SpecOutput format', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = toSpecOutputFeatures(features); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(toSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('fromSpecOutputFeatures', () => { + it('should convert from SpecOutput format', () => { + const specFeatures = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = fromSpecOutputFeatures(specFeatures); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(fromSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('roundtrip', () => { + it('should maintain data integrity through extract -> update cycle', () => { + const originalSpec = ` + + Test + + Testing + + + + Test & Feature + Uses <special> chars + + src/test.ts + + + +`; + + // Extract features + const features = extractImplementedFeatures(originalSpec); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses chars'); + + // Update with same features + const result = updateImplementedFeaturesSection(originalSpec, features); + + // Re-extract and verify + const reExtracted = extractImplementedFeatures(result); + expect(reExtracted[0].name).toBe('Test & Feature'); + expect(reExtracted[0].description).toBe('Uses chars'); + expect(reExtracted[0].file_locations).toEqual(['src/test.ts']); + }); + }); + + describe('custom logger', () => { + it('should use custom logger for extractXmlSection', () => { + const mockLogger = createMockLogger(); + const xml = '
content
'; + extractXmlSection(xml, 'section', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted
section'); + }); + + it('should log when section is not found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractXmlSection(xml, 'missing', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Section not found'); + }); + + it('should use custom logger for extractXmlElements', () => { + const mockLogger = createMockLogger(); + const xml = 'onetwo'; + extractXmlElements(xml, 'item', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 2 elements'); + }); + + it('should use custom logger for extractImplementedFeatures', () => { + const mockLogger = createMockLogger(); + const xml = ` + + Test + Desc + + `; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 1 implemented features'); + }); + + it('should log when no implemented_features section found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('No implemented_features section found'); + }); + + it('should use custom logger warn for missing insertion point', () => { + const mockLogger = createMockLogger(); + // XML without project_specification, core_capabilities, or implemented_features + const xml = 'content'; + const features: ImplementedFeature[] = [{ name: 'Test', description: 'Desc' }]; + updateImplementedFeaturesSection(xml, features, { logger: mockLogger }); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Could not find appropriate insertion point for implemented_features' + ); + }); + }); + + describe('edge cases', () => { + describe('escapeXml edge cases', () => { + it('should handle strings with only special characters', () => { + expect(escapeXml('<>&"\'')).toBe('<>&"''); + }); + + it('should handle very long strings', () => { + const longString = 'a'.repeat(10000) + '&' + 'b'.repeat(10000); + const escaped = escapeXml(longString); + expect(escaped).toContain('&'); + expect(escaped.length).toBe(20005); // +4 for & minus & + }); + + it('should handle unicode characters without escaping', () => { + const unicode = '日本語 emoji: 🚀 symbols: ∞ ≠ ≤'; + expect(escapeXml(unicode)).toBe(unicode); + }); + }); + + describe('unescapeXml edge cases', () => { + it('should handle strings with only entities', () => { + expect(unescapeXml('<>&"'')).toBe('<>&"\''); + }); + + it('should not double-unescape', () => { + // &lt; should become < (not <) + expect(unescapeXml('&lt;')).toBe('<'); + }); + + it('should handle partial/invalid entities gracefully', () => { + // Invalid entities should pass through unchanged + expect(unescapeXml('&unknown;')).toBe('&unknown;'); + expect(unescapeXml('&')).toBe('&'); // Missing semicolon + }); + }); + + describe('extractXmlSection edge cases', () => { + it('should handle nested tags with same name', () => { + // Note: regex-based parsing with non-greedy matching will match + // from first opening tag to first closing tag + const xml = 'inner'; + // Non-greedy [\s\S]*? matches from first to first + expect(extractXmlSection(xml, 'outer')).toBe('inner'); + }); + + it('should handle self-closing tags (returns null)', () => { + const xml = '
'; + // Regex expects content between tags, self-closing won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle tags with attributes', () => { + const xml = '
content
'; + // The regex matches exact tag names, so this won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle whitespace in tag content', () => { + const xml = '
\n\t
'; + expect(extractXmlSection(xml, 'section')).toBe(' \n\t '); + }); + }); + + describe('extractXmlElements edge cases', () => { + it('should handle elements across multiple lines', () => { + const xml = ` + + first + + second + `; + // Multiline content is now captured with [\s\S]*? pattern + const result = extractXmlElements(xml, 'item'); + expect(result).toHaveLength(2); + expect(result[0]).toBe('first'); + expect(result[1]).toBe('second'); + }); + + it('should handle consecutive elements without whitespace', () => { + const xml = 'abc'; + expect(extractXmlElements(xml, 'item')).toEqual(['a', 'b', 'c']); + }); + }); + + describe('extractImplementedFeatures edge cases', () => { + it('should skip features without names', () => { + const xml = ` + + Orphan description + + + Valid Feature + Has name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(1); + expect(features[0].name).toBe('Valid Feature'); + }); + + it('should handle features with empty names', () => { + const xml = ` + + + Empty name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Empty name is falsy + }); + + it('should handle features with whitespace-only names', () => { + const xml = ` + + + Whitespace name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Trimmed whitespace is empty + }); + + it('should handle empty file_locations section', () => { + const xml = ` + + Test + Desc + + + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].file_locations).toBeUndefined(); + }); + }); + + describe('featureToXml edge cases', () => { + it('should handle custom indentation', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + }; + const xml = featureToXml(feature, '\t'); + expect(xml).toContain('\t\t'); + expect(xml).toContain('\t\t\tTest'); + }); + + it('should handle empty description', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: '', + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + }); + + it('should handle undefined file_locations', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + file_locations: undefined, + }; + const xml = featureToXml(feature); + expect(xml).not.toContain('file_locations'); + }); + }); + + describe('updateImplementedFeaturesSection edge cases', () => { + it('should insert before as fallback', () => { + const specWithoutCoreCapabilities = ` + + Test +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutCoreCapabilities, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + expect(result.indexOf('')).toBeLessThan( + result.indexOf('') + ); + }); + + it('should return unchanged content when no insertion point found', () => { + const invalidSpec = 'content'; + const newFeatures: ImplementedFeature[] = [{ name: 'Feature', description: 'Desc' }]; + const result = updateImplementedFeaturesSection(invalidSpec, newFeatures); + expect(result).toBe(invalidSpec); + }); + + it('should handle empty features array', () => { + const spec = ` + + Old + Old desc + + `; + const result = updateImplementedFeaturesSection(spec, []); + expect(result).toContain(''); + expect(result).not.toContain('Old'); + }); + }); + + describe('addImplementedFeature edge cases', () => { + it('should create section when adding to spec without implemented_features', () => { + const specWithoutSection = ` + + Testing + +`; + const newFeature: ImplementedFeature = { + name: 'First Feature', + description: 'First description', + }; + const result = addImplementedFeature(specWithoutSection, newFeature); + expect(result).toContain(''); + expect(result).toContain('First Feature'); + }); + + it('should handle feature with all fields populated', () => { + const spec = ``; + const newFeature: ImplementedFeature = { + name: 'Complete Feature', + description: 'Full description', + file_locations: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }; + const result = addImplementedFeature(spec, newFeature); + expect(result).toContain('Complete Feature'); + expect(result).toContain('src/a.ts'); + expect(result).toContain('src/b.ts'); + expect(result).toContain('src/c.ts'); + }); + }); + + describe('updateImplementedFeature edge cases', () => { + it('should allow updating feature name', () => { + const spec = ` + + Old Name + Desc + + `; + const result = updateImplementedFeature(spec, 'Old Name', { + name: 'New Name', + }); + expect(result).toContain('New Name'); + expect(result).not.toContain('Old Name'); + }); + + it('should allow clearing file_locations', () => { + const spec = ` + + Test + Desc + + src/old.ts + + + `; + const result = updateImplementedFeature(spec, 'Test', { + file_locations: [], + }); + expect(result).not.toContain('file_locations'); + expect(result).not.toContain('src/old.ts'); + }); + + it('should handle updating multiple fields at once', () => { + const spec = ` + + Original + Original desc + + `; + const result = updateImplementedFeature(spec, 'Original', { + name: 'Updated', + description: 'Updated desc', + file_locations: ['new/path.ts'], + }); + expect(result).toContain('Updated'); + expect(result).toContain('Updated desc'); + expect(result).toContain('new/path.ts'); + }); + }); + + describe('toSpecOutputFeatures and fromSpecOutputFeatures edge cases', () => { + it('should handle features with empty file_locations array', () => { + const features: ImplementedFeature[] = [ + { name: 'Test', description: 'Desc', file_locations: [] }, + ]; + const specOutput = toSpecOutputFeatures(features); + expect(specOutput[0].file_locations).toBeUndefined(); + }); + + it('should handle round-trip conversion', () => { + const original: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f.ts'] }, + ]; + const specOutput = toSpecOutputFeatures(original); + const restored = fromSpecOutputFeatures(specOutput); + expect(restored).toEqual(original); + }); + }); + }); + + describe('integration scenarios', () => { + it('should handle a complete spec file workflow', () => { + // Start with a minimal spec + let spec = ` + + My App + + User management + +`; + + // Add first feature + spec = addImplementedFeature(spec, { + name: 'User Authentication', + description: 'Login and logout functionality', + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts'], + }); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + + // Add second feature + spec = addImplementedFeature(spec, { + name: 'User Profile', + description: 'View and edit user profile', + }); + expect(extractImplementedFeatureNames(spec)).toEqual(['User Authentication', 'User Profile']); + + // Update first feature + spec = updateImplementedFeature(spec, 'User Authentication', { + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts', 'src/auth/session.ts'], + }); + const features = extractImplementedFeatures(spec); + expect(features[0].file_locations).toContain('src/auth/session.ts'); + + // Remove a feature + spec = removeImplementedFeature(spec, 'User Profile'); + expect(hasImplementedFeature(spec, 'User Profile')).toBe(false); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + }); + + it('should handle special characters throughout workflow', () => { + const spec = ` + +`; + + const result = addImplementedFeature(spec, { + name: 'Search & Filter', + description: 'Supports syntax with "quoted" terms', + file_locations: ["src/search/parser's.ts"], + }); + + const features = extractImplementedFeatures(result); + expect(features[0].name).toBe('Search & Filter'); + expect(features[0].description).toBe('Supports syntax with "quoted" terms'); + expect(features[0].file_locations?.[0]).toBe("src/search/parser's.ts"); + }); + + it('should preserve other XML content when modifying features', () => { + const spec = ` + + Preserved Name + This should be preserved + + Capability 1 + Capability 2 + + + + Old Feature + Will be replaced + + + Keep this too +`; + + const result = updateImplementedFeaturesSection(spec, [ + { name: 'New Feature', description: 'New desc' }, + ]); + + expect(result).toContain('Preserved Name'); + expect(result).toContain('This should be preserved'); + expect(result).toContain('Capability 1'); + expect(result).toContain('Capability 2'); + expect(result).toContain('Keep this too'); + expect(result).not.toContain('Old Feature'); + expect(result).toContain('New Feature'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index b3d2df79..c3f83f8f 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -286,6 +286,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -312,6 +313,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); @@ -339,6 +341,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', + model: 'claude-opus-4-5-20251101', cwd: '/test', }); diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index 6ca69d86..ee9c7bad 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -11,6 +11,11 @@ import { getCodexConfigDir, getCodexAuthIndicators, } from '@automaker/platform'; +import { + calculateReasoningTimeout, + REASONING_TIMEOUT_MULTIPLIERS, + DEFAULT_TIMEOUT_MS, +} from '@automaker/types'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV]; @@ -289,5 +294,121 @@ describe('codex-provider.ts', () => { expect(codexRunMock).not.toHaveBeenCalled(); expect(spawnJSONLProcess).toHaveBeenCalled(); }); + + it('passes extended timeout for high reasoning effort', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Complex reasoning task', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + reasoningEffort: 'high', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // High reasoning effort should have 3x the default timeout (90000ms) + expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + }); + + it('passes extended timeout for xhigh reasoning effort', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Very complex reasoning task', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + reasoningEffort: 'xhigh', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // xhigh reasoning effort should have 4x the default timeout (120000ms) + expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh); + }); + + it('uses default timeout when no reasoning effort is specified', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Simple task', + model: 'gpt-5.2', + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // No reasoning effort should use the default timeout + expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS); + }); + }); + + describe('calculateReasoningTimeout', () => { + it('returns default timeout when no reasoning effort is specified', () => { + expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS); + expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('returns default timeout for none reasoning effort', () => { + expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('applies correct multiplier for minimal reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal); + expect(calculateReasoningTimeout('minimal')).toBe(expected); + }); + + it('applies correct multiplier for low reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low); + expect(calculateReasoningTimeout('low')).toBe(expected); + }); + + it('applies correct multiplier for medium reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium); + expect(calculateReasoningTimeout('medium')).toBe(expected); + }); + + it('applies correct multiplier for high reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + expect(calculateReasoningTimeout('high')).toBe(expected); + }); + + it('applies correct multiplier for xhigh reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh); + expect(calculateReasoningTimeout('xhigh')).toBe(expected); + }); + + it('uses custom base timeout when provided', () => { + const customBase = 60000; + expect(calculateReasoningTimeout('high', customBase)).toBe( + Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high) + ); + }); + + it('falls back to 1.0 multiplier for invalid reasoning effort', () => { + // Test that invalid values fallback gracefully to default multiplier + // This tests the defensive ?? 1.0 in calculateReasoningTimeout + const invalidEffort = 'invalid_effort' as never; + expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('produces expected absolute timeout values', () => { + // Verify the actual timeout values that will be used: + // none: 30000ms (30s) + // minimal: 36000ms (36s) + // low: 45000ms (45s) + // medium: 60000ms (1m) + // high: 90000ms (1m 30s) + // xhigh: 120000ms (2m) + expect(calculateReasoningTimeout('none')).toBe(30000); + expect(calculateReasoningTimeout('minimal')).toBe(36000); + expect(calculateReasoningTimeout('low')).toBe(45000); + expect(calculateReasoningTimeout('medium')).toBe(60000); + expect(calculateReasoningTimeout('high')).toBe(90000); + expect(calculateReasoningTimeout('xhigh')).toBe(120000); + }); }); }); diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts index 78619d38..7c3f908a 100644 --- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts @@ -202,8 +202,17 @@ describe('auto-mode-service.ts - Planning Mode', () => { }); describe('buildFeaturePrompt', () => { - const buildFeaturePrompt = (svc: any, feature: any) => { - return svc.buildFeaturePrompt(feature); + const defaultTaskExecutionPrompts = { + implementationInstructions: 'Test implementation instructions', + playwrightVerificationInstructions: 'Test playwright instructions', + }; + + const buildFeaturePrompt = ( + svc: any, + feature: any, + taskExecutionPrompts = defaultTaskExecutionPrompts + ) => { + return svc.buildFeaturePrompt(feature, taskExecutionPrompts); }; it('should include feature ID and description', () => { @@ -242,14 +251,15 @@ describe('auto-mode-service.ts - Planning Mode', () => { expect(result).toContain('/tmp/image2.jpg'); }); - it('should include summary tags instruction', () => { + it('should include implementation instructions', () => { const feature = { id: 'feat-123', description: 'Test feature', }; const result = buildFeaturePrompt(service, feature); - expect(result).toContain(''); - expect(result).toContain(''); + // The prompt should include the implementation instructions passed to it + expect(result).toContain('Test implementation instructions'); + expect(result).toContain('Test playwright instructions'); }); }); diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 65699ca6..07ad13c9 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -91,7 +91,7 @@ describe('claude-usage-service.ts', () => { it("should use 'where' command on Windows", async () => { vi.mocked(os.platform).mockReturnValue('win32'); - const windowsService = new ClaudeUsageService(); // Create new service after platform mock + const ptyService = new ClaudeUsageService(); // Create new service after platform mock mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { if (event === 'close') { @@ -100,7 +100,7 @@ describe('claude-usage-service.ts', () => { return mockSpawnProcess; }); - await windowsService.isAvailable(); + await ptyService.isAvailable(); expect(spawn).toHaveBeenCalledWith('where', ['claude']); }); @@ -403,120 +403,22 @@ Resets Jan 15, 3pm }); }); - describe('executeClaudeUsageCommandMac', () => { - beforeEach(() => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' }); - }); - - it('should execute expect script and return output', async () => { - const mockOutput = ` -Current session -65% left -Resets in 2h -`; - - let stdoutCallback: Function; - let closeCallback: Function; - - mockSpawnProcess.stdout = { - on: vi.fn((event: string, callback: Function) => { - if (event === 'data') { - stdoutCallback = callback; - } - }), - }; - mockSpawnProcess.stderr = { - on: vi.fn(), - }; - mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { - if (event === 'close') { - closeCallback = callback; - } - return mockSpawnProcess; - }); - - const promise = service.fetchUsageData(); - - // Simulate stdout data - stdoutCallback!(Buffer.from(mockOutput)); - - // Simulate successful close - closeCallback!(0); - - const result = await promise; - - expect(result.sessionPercentage).toBe(35); // 100 - 65 - expect(spawn).toHaveBeenCalledWith( - 'expect', - expect.arrayContaining(['-c']), - expect.any(Object) - ); - }); - - it('should handle authentication errors', async () => { - const mockOutput = 'token_expired'; - - let stdoutCallback: Function; - let closeCallback: Function; - - mockSpawnProcess.stdout = { - on: vi.fn((event: string, callback: Function) => { - if (event === 'data') { - stdoutCallback = callback; - } - }), - }; - mockSpawnProcess.stderr = { - on: vi.fn(), - }; - mockSpawnProcess.on = vi.fn((event: string, callback: Function) => { - if (event === 'close') { - closeCallback = callback; - } - return mockSpawnProcess; - }); - - const promise = service.fetchUsageData(); - - stdoutCallback!(Buffer.from(mockOutput)); - closeCallback!(1); - - await expect(promise).rejects.toThrow('Authentication required'); - }); - - it('should handle timeout with no data', async () => { - vi.useFakeTimers(); - - mockSpawnProcess.stdout = { - on: vi.fn(), - }; - mockSpawnProcess.stderr = { - on: vi.fn(), - }; - mockSpawnProcess.on = vi.fn(() => mockSpawnProcess); - mockSpawnProcess.kill = vi.fn(); - - const promise = service.fetchUsageData(); - - // Advance time past timeout (30 seconds) - vi.advanceTimersByTime(31000); - - await expect(promise).rejects.toThrow('Command timed out'); - - vi.useRealTimers(); + // Note: executeClaudeUsageCommandMac tests removed - the service now uses PTY for all platforms + // The executeClaudeUsageCommandMac method exists but is dead code (never called) + describe.skip('executeClaudeUsageCommandMac (deprecated - uses PTY now)', () => { + it('should be skipped - service now uses PTY for all platforms', () => { + expect(true).toBe(true); }); }); - describe('executeClaudeUsageCommandWindows', () => { + describe('executeClaudeUsageCommandPty', () => { + // Note: The service now uses PTY for all platforms, using process.cwd() as the working directory beforeEach(() => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser'); - vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' }); }); - it('should use node-pty on Windows and return output', async () => { - const windowsService = new ClaudeUsageService(); // Create new service for Windows platform + it('should use node-pty and return output', async () => { + const ptyService = new ClaudeUsageService(); const mockOutput = ` Current session 65% left @@ -538,7 +440,7 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - const promise = windowsService.fetchUsageData(); + const promise = ptyService.fetchUsageData(); // Simulate data dataCallback!(mockOutput); @@ -549,16 +451,19 @@ Resets in 2h const result = await promise; expect(result.sessionPercentage).toBe(35); + // Service uses process.cwd() for --add-dir expect(pty.spawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'], - expect.any(Object) + ['/c', 'claude', '--add-dir', process.cwd()], + expect.objectContaining({ + cwd: process.cwd(), + }) ); }); it('should send escape key after seeing usage data', async () => { vi.useFakeTimers(); - const windowsService = new ClaudeUsageService(); + const ptyService = new ClaudeUsageService(); const mockOutput = 'Current session\n65% left'; @@ -577,7 +482,7 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - const promise = windowsService.fetchUsageData(); + const promise = ptyService.fetchUsageData(); // Simulate seeing usage data dataCallback!(mockOutput); @@ -594,8 +499,8 @@ Resets in 2h vi.useRealTimers(); }); - it('should handle authentication errors on Windows', async () => { - const windowsService = new ClaudeUsageService(); + it('should handle authentication errors', async () => { + const ptyService = new ClaudeUsageService(); let dataCallback: Function | undefined; let exitCallback: Function | undefined; @@ -611,18 +516,22 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - const promise = windowsService.fetchUsageData(); + const promise = ptyService.fetchUsageData(); - dataCallback!('authentication_error'); + // Send data containing the authentication error pattern the service looks for + dataCallback!('"type":"authentication_error"'); + + // Trigger the exit handler which checks for auth errors + exitCallback!({ exitCode: 1 }); await expect(promise).rejects.toThrow( "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." ); }); - it('should handle timeout with no data on Windows', async () => { + it('should handle timeout with no data', async () => { vi.useFakeTimers(); - const windowsService = new ClaudeUsageService(); + const ptyService = new ClaudeUsageService(); const mockPty = { onData: vi.fn(), @@ -633,7 +542,7 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - const promise = windowsService.fetchUsageData(); + const promise = ptyService.fetchUsageData(); // Advance time past timeout (45 seconds) vi.advanceTimersByTime(46000); @@ -648,7 +557,7 @@ Resets in 2h it('should return data on timeout if data was captured', async () => { vi.useFakeTimers(); - const windowsService = new ClaudeUsageService(); + const ptyService = new ClaudeUsageService(); let dataCallback: Function | undefined; @@ -663,7 +572,7 @@ Resets in 2h }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); - const promise = windowsService.fetchUsageData(); + const promise = ptyService.fetchUsageData(); // Simulate receiving usage data dataCallback!('Current session\n65% left\nResets in 2h'); diff --git a/apps/server/tests/unit/services/feature-loader.test.ts b/apps/server/tests/unit/services/feature-loader.test.ts index dc540982..d3d0bbdc 100644 --- a/apps/server/tests/unit/services/feature-loader.test.ts +++ b/apps/server/tests/unit/services/feature-loader.test.ts @@ -190,9 +190,10 @@ describe('feature-loader.ts', () => { const result = await loader.getAll(testProjectPath); expect(result).toEqual([]); + // With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader expect(consoleSpy).toHaveBeenCalledWith( - expect.stringMatching(/WARN.*\[FeatureLoader\]/), - expect.stringContaining('Failed to parse feature.json') + expect.stringMatching(/WARN.*\[AtomicWriter\]/), + expect.stringContaining('unavailable') ); consoleSpy.mockRestore(); @@ -260,10 +261,13 @@ describe('feature-loader.ts', () => { expect(result).toBeNull(); }); - it('should throw on other errors', async () => { + it('should return null on other errors (with recovery attempt)', async () => { + // With recovery-enabled reads, get() returns null instead of throwing + // because it attempts to recover from backups before giving up vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); - await expect(loader.get(testProjectPath, 'feature-123')).rejects.toThrow('Permission denied'); + const result = await loader.get(testProjectPath, 'feature-123'); + expect(result).toBeNull(); }); }); @@ -442,4 +446,471 @@ describe('feature-loader.ts', () => { ); }); }); + + describe('findByTitle', () => { + it('should find feature by exact title match (case-insensitive)', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Logout Feature', + category: 'auth', + description: 'Logout implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + expect(result?.title).toBe('Login Feature'); + }); + + it('should return null when title is not found', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature'); + + expect(result).toBeNull(); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findByTitle(testProjectPath, ''); + const result2 = await loader.findByTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should skip features without titles', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + // no title + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Login Feature', + category: 'auth', + description: 'Another login', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Login Feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-2000-def'); + }); + }); + + describe('findDuplicateTitle', () => { + it('should find duplicate title', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, 'my feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should exclude specified feature ID from duplicate check', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should not find duplicate when excluding the feature that has the title + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-1000-abc' + ); + + expect(result).toBeNull(); + }); + + it('should find duplicate when title exists on different feature', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-2000-def' + ); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findDuplicateTitle(testProjectPath, ''); + const result2 = await loader.findDuplicateTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should handle titles with leading/trailing whitespace', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature '); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + }); + + describe('syncFeatureToAppSpec', () => { + const sampleAppSpec = ` + + Test Project + + Testing + + + + Existing Feature + Already implemented + + +`; + + const appSpecWithoutFeatures = ` + + Test Project + + Testing + +`; + + it('should add feature to app_spec.txt', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('app_spec.txt'), + expect.stringContaining('New Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('A new feature description'), + 'utf-8' + ); + }); + + it('should add feature with file locations', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature With Locations', + category: 'backend', + description: 'Feature with file locations', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [ + 'src/feature.ts', + 'src/utils/helper.ts', + ]); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/feature.ts'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/utils/helper.ts'), + 'utf-8' + ); + }); + + it('should return false when app_spec.txt does not exist', async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should return false when feature already exists (duplicate)', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + + const feature = { + id: 'feature-5678-xyz', + title: 'Existing Feature', // Same name as existing feature + category: 'ui', + description: 'Different description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should use feature ID as fallback name when title is missing', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + category: 'ui', + description: 'Feature without title', + // No title property + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Feature: feature-1234-abc'), + 'utf-8' + ); + }); + + it('should handle app_spec without implemented_features section', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'First Feature', + category: 'ui', + description: 'First implemented feature', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining(''), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('First Feature'), + 'utf-8' + ); + }); + + it('should throw on non-ENOENT file read errors', async () => { + const error = new Error('Permission denied'); + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow( + 'Permission denied' + ); + }); + + it('should preserve existing features when adding a new one', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature); + + // Verify both old and new features are in the output + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Existing Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('New Feature'), + 'utf-8' + ); + }); + + it('should escape special characters in feature name and description', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature with & "chars"', + category: 'ui', + description: 'Description with & "quotes"', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + // The XML should have escaped characters + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('<special>'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('&'), + 'utf-8' + ); + }); + + it('should not add empty file_locations array', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature Without Locations', + category: 'ui', + description: 'No file locations', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature, []); + + // File locations should not be included when array is empty + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = writeCall[1] as string; + + // Count occurrences of file_locations - should only have the one from Existing Feature if any + // The new feature should not add file_locations + expect(writtenContent).toContain('Feature Without Locations'); + }); + }); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index b28ad8c7..72755463 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.11.0", + "version": "0.12.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -48,6 +48,22 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@fontsource/cascadia-code": "^5.2.3", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@fontsource/iosevka": "^5.2.5", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/lato": "^5.2.7", + "@fontsource/montserrat": "^5.2.8", + "@fontsource/open-sans": "^5.2.7", + "@fontsource/poppins": "^5.2.7", + "@fontsource/raleway": "^5.2.8", + "@fontsource/roboto": "^5.2.9", + "@fontsource/source-code-pro": "^5.2.7", + "@fontsource/source-sans-3": "^5.2.9", + "@fontsource/work-sans": "^5.2.8", "@lezer/highlight": "1.2.3", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", @@ -204,12 +220,34 @@ "arch": [ "x64" ] + }, + { + "target": "rpm", + "arch": [ + "x64" + ] } ], "category": "Development", "icon": "public/logo_larger.png", "maintainer": "webdevcody@gmail.com", - "executableName": "automaker" + "executableName": "automaker", + "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", + "synopsis": "AI-powered autonomous development studio" + }, + "rpm": { + "depends": [ + "gtk3", + "libnotify", + "nss", + "libXScrnSaver", + "libXtst", + "xdg-utils", + "at-spi2-core", + "libuuid" + ], + "compression": "xz", + "vendor": "AutoMaker Team" }, "nsis": { "oneClick": false, diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index c27cd5e7..814dd38e 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -8,6 +8,7 @@ import { useCursorStatusInit } from './hooks/use-cursor-status-init'; import { useProviderAuthInit } from './hooks/use-provider-auth-init'; import './styles/global.css'; import './styles/theme-imports'; +import './styles/font-imports'; const logger = createLogger('App'); diff --git a/apps/ui/src/assets/fonts/zed/zed-fonts.css b/apps/ui/src/assets/fonts/zed/zed-fonts.css new file mode 100644 index 00000000..1b62a128 --- /dev/null +++ b/apps/ui/src/assets/fonts/zed/zed-fonts.css @@ -0,0 +1,67 @@ +/* Zed Fonts - https://github.com/zed-industries/zed-fonts */ + +/* Zed Sans - UI Font */ +@font-face { + font-family: 'Zed Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./zed-sans-extended.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Sans'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./zed-sans-extendeditalic.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./zed-sans-extendedbold.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Sans'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url('./zed-sans-extendedbolditalic.ttf') format('truetype'); +} + +/* Zed Mono - Code Font */ +@font-face { + font-family: 'Zed Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./zed-mono-extended.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Mono'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./zed-mono-extendeditalic.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./zed-mono-extendedbold.ttf') format('truetype'); +} + +@font-face { + font-family: 'Zed Mono'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url('./zed-mono-extendedbolditalic.ttf') format('truetype'); +} diff --git a/apps/ui/src/assets/fonts/zed/zed-mono-extended.ttf b/apps/ui/src/assets/fonts/zed/zed-mono-extended.ttf new file mode 100644 index 00000000..1cab6121 Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-mono-extended.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-mono-extendedbold.ttf b/apps/ui/src/assets/fonts/zed/zed-mono-extendedbold.ttf new file mode 100644 index 00000000..5c027864 Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-mono-extendedbold.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-mono-extendedbolditalic.ttf b/apps/ui/src/assets/fonts/zed/zed-mono-extendedbolditalic.ttf new file mode 100644 index 00000000..47a3a9bc Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-mono-extendedbolditalic.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-mono-extendeditalic.ttf b/apps/ui/src/assets/fonts/zed/zed-mono-extendeditalic.ttf new file mode 100644 index 00000000..85ff2c3c Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-mono-extendeditalic.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-sans-extended.ttf b/apps/ui/src/assets/fonts/zed/zed-sans-extended.ttf new file mode 100644 index 00000000..d5cc91e2 Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-sans-extended.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf b/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf new file mode 100644 index 00000000..0e1264b1 Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf b/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf new file mode 100644 index 00000000..568d9930 Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf differ diff --git a/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf b/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf new file mode 100644 index 00000000..d5f10a2e Binary files /dev/null and b/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf differ diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx index 31ce1d3d..4c243fe6 100644 --- a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -448,7 +448,9 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { ); const getIconComponent = (iconName: string) => { - return (LucideIcons as Record>)[iconName]; + return (LucideIcons as unknown as Record>)[ + iconName + ]; }; return ( diff --git a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx new file mode 100644 index 00000000..adcd7b64 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx @@ -0,0 +1,207 @@ +/** + * Notification Bell - Bell icon with unread count and popover + */ + +import { useCallback } from 'react'; +import { Bell, Check, Trash2, ExternalLink } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { useNotificationsStore } from '@/store/notifications-store'; +import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { Notification } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +/** + * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago") + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'just now'; + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; + if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`; + if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`; + return date.toLocaleDateString(); +} + +interface NotificationBellProps { + projectPath: string | null; +} + +export function NotificationBell({ projectPath }: NotificationBellProps) { + const navigate = useNavigate(); + const { + notifications, + unreadCount, + isPopoverOpen, + setPopoverOpen, + markAsRead, + dismissNotification, + } = useNotificationsStore(); + + // Load notifications and subscribe to events + useLoadNotifications(projectPath); + useNotificationEvents(projectPath); + + const handleMarkAsRead = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + markAsRead(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.markAsRead(projectPath, notificationId); + }, + [projectPath, markAsRead] + ); + + const handleDismiss = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + dismissNotification(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.dismiss(projectPath, notificationId); + }, + [projectPath, dismissNotification] + ); + + const handleNotificationClick = useCallback( + (notification: Notification) => { + // Mark as read + handleMarkAsRead(notification.id); + setPopoverOpen(false); + + // Navigate to the relevant view based on notification type + if (notification.featureId) { + navigate({ to: '/board' }); + } + }, + [handleMarkAsRead, setPopoverOpen, navigate] + ); + + const handleViewAll = useCallback(() => { + setPopoverOpen(false); + navigate({ to: '/notifications' }); + }, [setPopoverOpen, navigate]); + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'feature_waiting_approval': + return ; + case 'feature_verified': + return ; + case 'spec_regeneration_complete': + return ; + default: + return ; + } + }; + + // Show recent 3 notifications in popover + const recentNotifications = notifications.slice(0, 3); + + if (!projectPath) { + return null; + } + + return ( + + + + + +
+

Notifications

+ {unreadCount > 0 && ( + {unreadCount} unread + )} +
+ + {recentNotifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( +
+ {recentNotifications.map((notification) => ( +
handleNotificationClick(notification)} + > +
{getNotificationIcon(notification.type)}
+
+
+

{notification.title}

+ {!notification.read && ( + + )} +
+

+ {notification.message} +

+

+ {formatRelativeTime(new Date(notification.createdAt))} +

+
+
+ +
+
+ ))} +
+ )} + + {notifications.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index 39a7b652..af63af32 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState, memo } from 'react'; +import { useEffect, useRef, useState, memo, useCallback } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; +import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -8,6 +9,9 @@ import type { Project } from '@/lib/electron'; import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; import { useThemePreview } from '@/components/layout/sidebar/hooks'; +// Constant for "use global theme" option +const USE_GLOBAL_THEME = '' as const; + // Constants for z-index values const Z_INDEX = { CONTEXT_MENU: 100, @@ -124,19 +128,26 @@ export function ProjectContextMenu({ } = useAppStore(); const [showRemoveDialog, setShowRemoveDialog] = useState(false); const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); + const [removeConfirmed, setRemoveConfirmed] = useState(false); const themeSubmenuRef = useRef(null); const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + const handleClickOutside = (event: globalThis.MouseEvent) => { + // Don't close if a confirmation dialog is open (dialog is in a portal) + if (showRemoveDialog) return; + + if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) { setPreviewTheme(null); onClose(); } }; - const handleEscape = (event: KeyboardEvent) => { + const handleEscape = (event: globalThis.KeyboardEvent) => { + // Don't close if a confirmation dialog is open (let the dialog handle escape) + if (showRemoveDialog) return; + if (event.key === 'Escape') { setPreviewTheme(null); onClose(); @@ -150,7 +161,7 @@ export function ProjectContextMenu({ document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; - }, [onClose, setPreviewTheme]); + }, [onClose, setPreviewTheme, showRemoveDialog]); const handleEdit = () => { onEdit(project); @@ -160,166 +171,187 @@ export function ProjectContextMenu({ setShowRemoveDialog(true); }; - const handleThemeSelect = (value: ThemeMode | '') => { - setPreviewTheme(null); - if (value !== '') { - setTheme(value); - } else { - setTheme(globalTheme); - } - setProjectTheme(project.id, value === '' ? null : value); - setShowThemeSubmenu(false); - }; + const handleThemeSelect = useCallback( + (value: ThemeMode | typeof USE_GLOBAL_THEME) => { + setPreviewTheme(null); + const isUsingGlobal = value === USE_GLOBAL_THEME; + setTheme(isUsingGlobal ? globalTheme : value); + setProjectTheme(project.id, isUsingGlobal ? null : value); + setShowThemeSubmenu(false); + }, + [globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme] + ); - const handleConfirmRemove = () => { + const handleConfirmRemove = useCallback(() => { moveProjectToTrash(project.id); - onClose(); - }; + toast.success('Project removed', { + description: `${project.name} has been removed from your projects list`, + }); + setRemoveConfirmed(true); + }, [moveProjectToTrash, project.id, project.name]); + + const handleDialogClose = useCallback( + (isOpen: boolean) => { + setShowRemoveDialog(isOpen); + // Close the context menu when dialog closes (whether confirmed or cancelled) + // This prevents the context menu from reappearing after dialog interaction + if (!isOpen) { + // Reset confirmation state + setRemoveConfirmed(false); + // Always close the context menu when dialog closes + onClose(); + } + }, + [onClose] + ); return ( <> -
-
- - - {/* Theme Submenu Trigger */} -
setShowThemeSubmenu(true)} - onMouseLeave={() => { - setShowThemeSubmenu(false); - setPreviewTheme(null); - }} - > + {/* Hide context menu when confirm dialog is open */} + {!showRemoveDialog && ( +
+
- {/* Theme Submenu */} - {showThemeSubmenu && ( -
setShowThemeSubmenu(true)} + onMouseLeave={() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + > + + + Project Theme + {project.theme && ( + + {project.theme} + + )} + + -
+ {/* Theme Submenu */} + {showThemeSubmenu && ( +
+
+ {/* Use Global Option */} + - {/* Two Column Layout - Using reusable ThemeColumn component */} -
- - +
+ + {/* Two Column Layout - Using reusable ThemeColumn component */} +
+ + +
-
- )} -
+ )} +
- + +
-
+ )} { if (project.icon && project.icon in LucideIcons) { - return (LucideIcons as Record)[project.icon]; + return (LucideIcons as unknown as Record)[project.icon]; } return Folder; }; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index 442413fd..9fa772da 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -1,20 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; -import { Plus, Bug, FolderOpen } from 'lucide-react'; -import { useNavigate } from '@tanstack/react-router'; +import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; +import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useOSDetection } from '@/hooks/use-os-detection'; import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectContextMenu } from './components/project-context-menu'; import { EditProjectDialog } from './components/edit-project-dialog'; +import { NotificationBell } from './components/notification-bell'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; +import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants'; import type { Project } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; +import type { FeatureCount } from '@/components/views/spec-view/types'; function getOSAbbreviation(os: string): string { switch (os) { @@ -31,6 +34,9 @@ function getOSAbbreviation(os: string): string { export function ProjectSwitcher() { const navigate = useNavigate(); + const location = useLocation(); + const { hideWiki } = SIDEBAR_FEATURE_FLAGS; + const isWikiActive = location.pathname === '/wiki'; const { projects, currentProject, @@ -52,7 +58,7 @@ export function ProjectSwitcher() { const [projectOverview, setProjectOverview] = useState(''); const [generateFeatures, setGenerateFeatures] = useState(true); const [analyzeProject, setAnalyzeProject] = useState(true); - const [featureCount, setFeatureCount] = useState(5); + const [featureCount, setFeatureCount] = useState(50); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -124,6 +130,10 @@ export function ProjectSwitcher() { api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); }, []); + const handleWikiClick = useCallback(() => { + navigate({ to: '/wiki' }); + }, [navigate]); + /** * Opens the system folder selection dialog and initializes the selected project. */ @@ -199,13 +209,18 @@ export function ProjectSwitcher() { try { const api = getElectronAPI(); - await api.generateAppSpec({ - projectPath: setupProjectPath, + if (!api.specRegeneration) { + toast.error('Spec regeneration not available'); + setSpecCreatingForProject(null); + return; + } + await api.specRegeneration.create( + setupProjectPath, projectOverview, generateFeatures, analyzeProject, - featureCount, - }); + featureCount + ); } catch (error) { console.error('Failed to generate spec:', error); toast.error('Failed to generate spec', { @@ -319,6 +334,11 @@ export function ProjectSwitcher() { v{appVersion} {versionSuffix} + + {/* Notification Bell */} +
+ +
@@ -405,8 +425,37 @@ export function ProjectSwitcher() { )}
- {/* Bug Report Button at the very bottom */} -
+ {/* Wiki and Bug Report Buttons at the very bottom */} +
+ {/* Wiki Button */} + {!hideWiki && ( + + )} + {/* Bug Report Button */} + )} +
- +