Compare commits

..

1 Commits

Author SHA1 Message Date
Test User
c7ebdb1f80 feat: Implement API key authentication with rate limiting and secure comparison
- Added rate limiting to the authentication middleware to prevent brute-force attacks.
- Introduced a secure comparison function to mitigate timing attacks during API key validation.
- Created a new rate limiter class to track failed authentication attempts and block requests after exceeding the maximum allowed failures.
- Updated the authentication middleware to handle rate limiting and secure key comparison.
- Enhanced error handling for rate-limited requests, providing appropriate responses to clients.
2025-12-24 14:49:47 -05:00
172 changed files with 2236 additions and 13452 deletions

3
.gitignore vendored
View File

@@ -79,5 +79,4 @@ blob-report/
# Misc # Misc
*.pem *.pem
docker-compose.override.yml docker-compose.override.yml
.claude/

172
CLAUDE.md
View File

@@ -1,172 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
## Common Commands
```bash
# Development
npm run dev # Interactive launcher (choose web or electron)
npm run dev:web # Web browser mode (localhost:3007)
npm run dev:electron # Desktop app mode
npm run dev:electron:debug # Desktop with DevTools open
# Building
npm run build # Build web application
npm run build:packages # Build all shared packages (required before other builds)
npm run build:electron # Build desktop app for current platform
npm run build:server # Build server only
# Testing
npm run test # E2E tests (Playwright, headless)
npm run test:headed # E2E tests with browser visible
npm run test:server # Server unit tests (Vitest)
npm run test:packages # All shared package tests
npm run test:all # All tests (packages + server)
# Single test file
npm run test:server -- tests/unit/specific.test.ts
# Linting and formatting
npm run lint # ESLint
npm run format # Prettier write
npm run format:check # Prettier check
```
## Architecture
### Monorepo Structure
```
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
│ └── server/ # Express + WebSocket backend (port 3008)
└── libs/ # Shared packages (@automaker/*)
├── types/ # Core TypeScript definitions (no dependencies)
├── utils/ # Logging, errors, image processing, context loading
├── prompts/ # AI prompt templates
├── platform/ # Path management, security, process spawning
├── model-resolver/ # Claude model alias resolution
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### Package Dependency Chain
Packages can only depend on packages above them:
```
@automaker/types (no dependencies)
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
@automaker/git-utils
@automaker/server, @automaker/ui
```
### Key Technologies
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
- **Testing**: Playwright (E2E), Vitest (unit)
### Server Architecture
The server (`apps/server/src/`) follows a modular pattern:
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
- `lib/` - Utilities (events, auth, worktree metadata)
### Frontend Architecture
The UI (`apps/ui/src/`) uses:
- `routes/` - TanStack Router file-based routing
- `components/views/` - Main view components (board, settings, terminal, etc.)
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
- `hooks/` - Custom React hooks
- `lib/` - Utilities and API client
## Data Storage
### Per-Project Data (`.automaker/`)
```
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json
│ ├── agent-output.md
│ └── images/
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
├── settings.json # Project-specific settings
├── spec.md # Project specification
└── analysis.json # Project structure analysis
```
### Global Data (`DATA_DIR`, default `./data`)
```
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
```
## Import Conventions
Always import from shared packages, never from old paths:
```typescript
// ✅ Correct
import type { Feature, ExecuteOptions } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import { getEnhancementPrompt } from '@automaker/prompts';
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
import { resolveModelString } from '@automaker/model-resolver';
import { resolveDependencies } from '@automaker/dependency-resolver';
import { getGitRepositoryDiffs } from '@automaker/git-utils';
// ❌ Never import from old paths
import { Feature } from '../services/feature-loader'; // Wrong
import { createLogger } from '../lib/logger'; // Wrong
```
## Key Patterns
### Event-Driven Architecture
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
### Git Worktree Isolation
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
### Context Files
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
### Model Resolution
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `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

412
README.md
View File

@@ -19,7 +19,7 @@
- [What Makes Automaker Different?](#what-makes-automaker-different) - [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow) - [The Workflow](#the-workflow)
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk) - [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters) - [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer) - [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support) - [Community & Support](#community--support)
@@ -28,36 +28,22 @@
- [Quick Start](#quick-start) - [Quick Start](#quick-start)
- [How to Run](#how-to-run) - [How to Run](#how-to-run)
- [Development Mode](#development-mode) - [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production) - [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing) - [Testing](#testing)
- [Linting](#linting) - [Linting](#linting)
- [Environment Configuration](#environment-configuration) - [Authentication Options](#authentication-options)
- [Authentication Setup](#authentication-setup) - [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features) - [Features](#features)
- [Core Workflow](#core-workflow)
- [AI & Planning](#ai--planning)
- [Project Management](#project-management)
- [Collaboration & Review](#collaboration--review)
- [Developer Tools](#developer-tools)
- [Advanced Features](#advanced-features)
- [Tech Stack](#tech-stack) - [Tech Stack](#tech-stack)
- [Frontend](#frontend)
- [Backend](#backend)
- [Testing & Quality](#testing--quality)
- [Shared Libraries](#shared-libraries)
- [Available Views](#available-views)
- [Architecture](#architecture)
- [Monorepo Structure](#monorepo-structure)
- [How It Works](#how-it-works)
- [Key Architectural Patterns](#key-architectural-patterns)
- [Security & Isolation](#security--isolation)
- [Data Storage](#data-storage)
- [Learn More](#learn-more) - [Learn More](#learn-more)
- [License](#license) - [License](#license)
</details> </details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution. Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png) ![Automaker UI](https://i.imgur.com/jdwKydM.png)
@@ -73,9 +59,9 @@ Traditional development tools help you write code. Automaker helps you **orchest
4. **Review & Verify** - Review the changes, run tests, and approve when ready 4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks 5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Agent SDK ### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention. Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters ### Why This Matters
@@ -109,7 +95,8 @@ In the Discord, you can:
- 🚀 Show off projects built with AI agents - 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors - 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU) 👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
--- ---
@@ -117,49 +104,28 @@ In the Discord, you can:
### Prerequisites ### Prerequisites
- **Node.js 18+** (tested with Node.js 22) - Node.js 18+
- **npm** (comes with Node.js) - npm
- **Authentication** (choose one): - [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
- **[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/))
### Quick Start ### Quick Start
```bash ```bash
# 1. Clone the repository # 1. Clone the repo
git clone https://github.com/AutoMaker-Org/automaker.git git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker cd automaker
# 2. Install dependencies # 2. Install dependencies
npm install npm install
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly) # 3. Build local shared packages
npm run build:packages npm run build:packages
# 4. Set up authentication (skip if using Claude Code CLI) # 4. Run Automaker (pick your mode)
# If using Claude Code CLI: credentials are detected automatically
# If using API key directly, choose one method:
# 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
# 5. Start Automaker (interactive launcher)
npm run dev npm run dev
# Choose between: # Then choose your run mode when prompted, or use specific commands below
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
``` ```
**Note:** The `npm run dev` command will:
- Check for dependencies and install if needed
- Install Playwright browsers for E2E tests
- Kill any processes on ports 3007/3008
- Present an interactive menu to choose your run mode
## How to Run ## How to Run
### Development Mode ### Development Mode
@@ -197,65 +163,31 @@ npm run dev:web
### Building for Production ### Building for Production
#### Web Application
```bash ```bash
# Build for web deployment (uses Vite) # Build Next.js app
npm run build npm run build
# Run production build # Build Electron app for distribution
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
npm run build:electron 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)
# Output directory: apps/ui/release/
``` ```
#### Docker Deployment ### Running Production Build
```bash ```bash
# Build and run with Docker Compose (recommended for security) # Start production Next.js server
docker-compose up -d npm run start
# Access at http://localhost:3007
# API at http://localhost:3008
``` ```
### Testing ### Testing
#### End-to-End Tests (Playwright)
```bash ```bash
npm run test # Headless E2E tests # Run tests headless
npm run test:headed # Browser visible E2E tests npm run test
# Run tests with browser visible
npm run test:headed
``` ```
#### Unit Tests (Vitest)
```bash
npm run test:server # Server unit tests
npm run test:server:coverage # Server tests with coverage
npm run test:packages # All shared package tests
npm run test:all # Packages + server tests
```
#### Test Configuration
- E2E tests run on ports 3007 (UI) and 3008 (server)
- Automatically starts test servers before running
- Uses Chromium browser via Playwright
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
### Linting ### Linting
```bash ```bash
@@ -263,283 +195,59 @@ npm run test:all # Packages + server tests
npm run lint npm run lint
``` ```
### Environment Configuration ### Authentication Options
#### Authentication (if not using Claude Code CLI) Automaker supports multiple authentication methods (in order of priority):
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI) | Method | Environment Variable | Description |
| ---------------- | -------------------- | ------------------------------- |
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
| API Key (stored) | — | Anthropic API key stored in app |
#### Optional - Server ### Persistent Setup (Optional)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
#### Optional - Security
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*)
#### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
### Authentication Setup
#### Option 1: Claude Code CLI (Recommended)
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`: Add to your `~/.bashrc` or `~/.zshrc`:
```bash ```bash
export ANTHROPIC_API_KEY="sk-ant-..." export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
``` ```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`). Then restart your terminal or run `source ~/.bashrc`.
##### 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 ## Features
### Core Workflow
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages - 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress" - 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch - 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion - 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them - 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
### AI & Planning - 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature - 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving - **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution) - 🧪 **Test Integration** - Automatic test running and verification for implemented features
- **Plan Approval** - Review and approve AI-generated plans before implementation begins - 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation - 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
### Project Management - ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure - 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
### Collaboration & Review
- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
### Developer Tools
- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
-**Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
### Advanced Features
- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
## Tech Stack ## Tech Stack
### Frontend - [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- **React 19** - UI framework - [Tailwind CSS](https://tailwindcss.com/) - Styling
- **Vite 7** - Build tool and development server - [Zustand](https://zustand-demo.pmnd.rs/) - State management
- **Electron 39** - Desktop application framework - [dnd-kit](https://dndkit.com/) - Drag and drop functionality
- **TypeScript 5.9** - Type safety
- **TanStack Router** - File-based routing
- **Zustand 5** - State management with persistence
- **Tailwind CSS 4** - Utility-first styling with 25+ themes
- **Radix UI** - Accessible component primitives
- **dnd-kit** - Drag and drop for Kanban board
- **@xyflow/react** - Graph visualization for dependencies
- **xterm.js** - Integrated terminal emulator
- **CodeMirror 6** - Code editor for XML/syntax highlighting
- **Lucide Icons** - Icon library
### Backend
- **Node.js** - JavaScript runtime with ES modules
- **Express 5** - HTTP server framework
- **TypeScript 5.9** - Type safety
- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
- **WebSocket (ws)** - Real-time event streaming
- **node-pty** - PTY terminal sessions
### Testing & Quality
- **Playwright** - End-to-end testing
- **Vitest** - Unit testing framework
- **ESLint 9** - Code linting
- **Prettier 3** - Code formatting
- **Husky** - Git hooks for pre-commit formatting
### Shared Libraries
- **@automaker/types** - Shared TypeScript definitions
- **@automaker/utils** - Logging, error handling, image processing
- **@automaker/prompts** - AI prompt templates
- **@automaker/platform** - Path management and security
- **@automaker/model-resolver** - Claude model alias resolution
- **@automaker/dependency-resolver** - Feature dependency ordering
- **@automaker/git-utils** - Git operations and worktree management
## Available Views
Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
| View | Shortcut | Description |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
| **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 |
| **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)
- **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project)
## Architecture
### Monorepo Structure
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
```text
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
└── libs/ # Shared packages
├── types/ # Core TypeScript definitions
├── utils/ # Logging, errors, utilities
├── prompts/ # AI prompt templates
├── platform/ # Path management, security
├── model-resolver/ # Claude model aliasing
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### How It Works
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
### Key Architectural Patterns
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
- **State Management** - Zustand with persistence for frontend state across restarts
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
### Security & Isolation
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
### Data Storage
Automaker uses a file-based storage system (no database required):
#### Per-Project Data
Stored in `{projectPath}/.automaker/`:
```text
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json # Feature metadata
│ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images
├── context/ # Context files for AI agents
├── settings.json # Project-specific settings
├── spec.md # Project specification
├── analysis.json # Project structure analysis
└── feature-suggestions.json # AI-generated suggestions
```
#### Global Data
Stored in `DATA_DIR` (default `./data`):
```text
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys (encrypted)
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
└── {sessionId}.json
```
## Learn More ## Learn More
### Documentation To learn more about Next.js, take a look at the following resources:
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
## License ## License

View File

@@ -28,7 +28,6 @@
"@automaker/prompts": "^1.0.0", "@automaker/prompts": "^1.0.0",
"@automaker/types": "^1.0.0", "@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0", "@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",

View File

@@ -48,12 +48,7 @@ import { createClaudeRoutes } from './routes/claude/index.js';
import { ClaudeUsageService } from './services/claude-usage-service.js'; import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js'; import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js'; import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
// Load environment variables // Load environment variables
dotenv.config(); dotenv.config();
@@ -105,13 +100,9 @@ if (ENABLE_REQUEST_LOGGING) {
}) })
); );
} }
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
// from malicious websites. MCP server endpoints can execute arbitrary commands,
// so allowing any origin would enable RCE from any website visited while Automaker runs.
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
app.use( app.use(
cors({ cors({
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS, origin: process.env.CORS_ORIGIN || '*',
credentials: true, credentials: true,
}) })
); );
@@ -121,13 +112,11 @@ app.use(express.json({ limit: '50mb' }));
const events: EventEmitter = createEventEmitter(); const events: EventEmitter = createEventEmitter();
// Create services // Create services
// Note: settingsService is created first so it can be injected into other services const agentService = new AgentService(DATA_DIR, events);
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader(); const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService); const autoModeService = new AutoModeService(events);
const settingsService = new SettingsService(DATA_DIR);
const claudeUsageService = new ClaudeUsageService(); const claudeUsageService = new ClaudeUsageService();
const mcpTestService = new MCPTestService(settingsService);
// Initialize services // Initialize services
(async () => { (async () => {
@@ -159,20 +148,17 @@ app.use('/api/enhance-prompt', createEnhancePromptRoutes());
app.use('/api/worktree', createWorktreeRoutes()); app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes()); app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes()); app.use('/api/setup', createSetupRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/suggestions', createSuggestionsRoutes(events));
app.use('/api/models', createModelsRoutes()); app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService)); app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
app.use('/api/workspace', createWorkspaceRoutes()); app.use('/api/workspace', createWorkspaceRoutes());
app.use('/api/templates', createTemplatesRoutes()); app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes()); app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/github', createGitHubRoutes(events));
app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/context', createContextRoutes());
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
// Create HTTP server // Create HTTP server
const server = createServer(app); const server = createServer(app);
@@ -201,31 +187,12 @@ server.on('upgrade', (request, socket, head) => {
// Events WebSocket connection handler // Events WebSocket connection handler
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
console.log('[WebSocket] Client connected, ready state:', ws.readyState); console.log('[WebSocket] Client connected');
// Subscribe to all events and forward to this client // Subscribe to all events and forward to this client
const unsubscribe = events.subscribe((type, payload) => { const unsubscribe = events.subscribe((type, payload) => {
console.log('[WebSocket] Event received:', {
type,
hasPayload: !!payload,
payloadKeys: payload ? Object.keys(payload) : [],
wsReadyState: ws.readyState,
wsOpen: ws.readyState === WebSocket.OPEN,
});
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type, payload }); ws.send(JSON.stringify({ type, payload }));
console.log('[WebSocket] Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as any)?.sessionId,
});
ws.send(message);
} else {
console.log(
'[WebSocket] WARNING: Cannot send event, WebSocket not open. ReadyState:',
ws.readyState
);
} }
}); });
@@ -235,7 +202,7 @@ wss.on('connection', (ws: WebSocket) => {
}); });
ws.on('error', (error) => { ws.on('error', (error) => {
console.error('[WebSocket] ERROR:', error); console.error('[WebSocket] Error:', error);
unsubscribe(); unsubscribe();
}); });
}); });

View File

@@ -2,9 +2,30 @@
* Authentication middleware for API security * Authentication middleware for API security
* *
* Supports API key authentication via header or environment variable. * Supports API key authentication via header or environment variable.
* Includes rate limiting to prevent brute-force attacks.
*/ */
import * as crypto from 'crypto';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { apiKeyRateLimiter } from './rate-limiter.js';
/**
* Performs a constant-time string comparison to prevent timing attacks.
* Uses crypto.timingSafeEqual with proper buffer handling.
*/
function secureCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a, 'utf8');
const bufferB = Buffer.from(b, 'utf8');
// If lengths differ, we still need to do a constant-time comparison
// to avoid leaking length information. We compare against bufferA twice.
if (bufferA.length !== bufferB.length) {
crypto.timingSafeEqual(bufferA, bufferA);
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
// API key from environment (optional - if not set, auth is disabled) // API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY; const API_KEY = process.env.AUTOMAKER_API_KEY;
@@ -14,6 +35,7 @@ const API_KEY = process.env.AUTOMAKER_API_KEY;
* *
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header. * If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
* If not set, allows all requests (development mode). * If not set, allows all requests (development mode).
* Includes rate limiting to prevent brute-force attacks.
*/ */
export function authMiddleware(req: Request, res: Response, next: NextFunction): void { export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// If no API key is configured, allow all requests // If no API key is configured, allow all requests
@@ -22,6 +44,22 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
const clientIp = apiKeyRateLimiter.getClientIp(req);
// Check if client is rate limited
if (apiKeyRateLimiter.isBlocked(clientIp)) {
const retryAfterMs = apiKeyRateLimiter.getBlockTimeRemaining(clientIp);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
error: 'Too many failed authentication attempts. Please try again later.',
retryAfter: retryAfterSeconds,
});
return;
}
// Check for API key in header // Check for API key in header
const providedKey = req.headers['x-api-key'] as string | undefined; const providedKey = req.headers['x-api-key'] as string | undefined;
@@ -33,7 +71,10 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
if (providedKey !== API_KEY) { if (!secureCompare(providedKey, API_KEY)) {
// Record failed attempt
apiKeyRateLimiter.recordFailure(clientIp);
res.status(403).json({ res.status(403).json({
success: false, success: false,
error: 'Invalid API key.', error: 'Invalid API key.',
@@ -41,6 +82,9 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
return; return;
} }
// Successful authentication - reset rate limiter for this IP
apiKeyRateLimiter.reset(clientIp);
next(); next();
} }

View File

@@ -0,0 +1,208 @@
/**
* In-memory rate limiter for authentication endpoints
*
* Provides brute-force protection by tracking failed attempts per IP address.
* Blocks requests after exceeding the maximum number of failures within a time window.
*/
import type { Request, Response, NextFunction } from 'express';
interface AttemptRecord {
count: number;
firstAttempt: number;
blockedUntil: number | null;
}
interface RateLimiterConfig {
maxAttempts: number;
windowMs: number;
blockDurationMs: number;
}
const DEFAULT_CONFIG: RateLimiterConfig = {
maxAttempts: 5,
windowMs: 15 * 60 * 1000, // 15 minutes
blockDurationMs: 15 * 60 * 1000, // 15 minutes
};
/**
* Rate limiter instance that tracks attempts by a key (typically IP address)
*/
export class RateLimiter {
private attempts: Map<string, AttemptRecord> = new Map();
private config: RateLimiterConfig;
constructor(config: Partial<RateLimiterConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Extract client IP address from request
* Handles proxied requests via X-Forwarded-For header
*/
getClientIp(req: Request): string {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
return forwardedIp.trim();
}
return req.socket.remoteAddress || 'unknown';
}
/**
* Check if a key is currently rate limited
*/
isBlocked(key: string): boolean {
const record = this.attempts.get(key);
if (!record) return false;
const now = Date.now();
// Check if currently blocked
if (record.blockedUntil && now < record.blockedUntil) {
return true;
}
// Clear expired block
if (record.blockedUntil && now >= record.blockedUntil) {
this.attempts.delete(key);
return false;
}
return false;
}
/**
* Get remaining time until block expires (in milliseconds)
*/
getBlockTimeRemaining(key: string): number {
const record = this.attempts.get(key);
if (!record?.blockedUntil) return 0;
const remaining = record.blockedUntil - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Record a failed authentication attempt
* Returns true if the key is now blocked
*/
recordFailure(key: string): boolean {
const now = Date.now();
const record = this.attempts.get(key);
if (!record) {
this.attempts.set(key, {
count: 1,
firstAttempt: now,
blockedUntil: null,
});
return false;
}
// If window has expired, reset the counter
if (now - record.firstAttempt > this.config.windowMs) {
this.attempts.set(key, {
count: 1,
firstAttempt: now,
blockedUntil: null,
});
return false;
}
// Increment counter
record.count += 1;
// Check if should be blocked
if (record.count >= this.config.maxAttempts) {
record.blockedUntil = now + this.config.blockDurationMs;
return true;
}
return false;
}
/**
* Clear a key's record (e.g., on successful authentication)
*/
reset(key: string): void {
this.attempts.delete(key);
}
/**
* Get the number of attempts remaining before block
*/
getAttemptsRemaining(key: string): number {
const record = this.attempts.get(key);
if (!record) return this.config.maxAttempts;
const now = Date.now();
// If window expired, full attempts available
if (now - record.firstAttempt > this.config.windowMs) {
return this.config.maxAttempts;
}
return Math.max(0, this.config.maxAttempts - record.count);
}
/**
* Clean up expired records to prevent memory leaks
*/
cleanup(): void {
const now = Date.now();
const keysToDelete: string[] = [];
this.attempts.forEach((record, key) => {
// Mark for deletion if block has expired
if (record.blockedUntil && now >= record.blockedUntil) {
keysToDelete.push(key);
return;
}
// Mark for deletion if window has expired and not blocked
if (!record.blockedUntil && now - record.firstAttempt > this.config.windowMs) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => this.attempts.delete(key));
}
}
// Shared rate limiter instances for authentication endpoints
export const apiKeyRateLimiter = new RateLimiter();
export const terminalAuthRateLimiter = new RateLimiter();
// Clean up expired records periodically (every 5 minutes)
setInterval(
() => {
apiKeyRateLimiter.cleanup();
terminalAuthRateLimiter.cleanup();
},
5 * 60 * 1000
);
/**
* Create rate limiting middleware for authentication endpoints
* This middleware checks if the request is rate limited before processing
*/
export function createRateLimitMiddleware(rateLimiter: RateLimiter) {
return (req: Request, res: Response, next: NextFunction): void => {
const clientIp = rateLimiter.getClientIp(req);
if (rateLimiter.isBlocked(clientIp)) {
const retryAfterMs = rateLimiter.getBlockTimeRemaining(clientIp);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
error: 'Too many failed authentication attempts. Please try again later.',
retryAfter: retryAfterSeconds,
});
return;
}
next();
};
}

View File

@@ -18,7 +18,7 @@
import type { Options } from '@anthropic-ai/claude-agent-sdk'; import type { Options } from '@anthropic-ai/claude-agent-sdk';
import path from 'path'; import path from 'path';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/** /**
@@ -136,106 +136,6 @@ function getBaseOptions(): Partial<Options> {
}; };
} }
/**
* MCP permission options result
*/
interface McpPermissionOptions {
/** Whether tools should be restricted to a preset */
shouldRestrictTools: boolean;
/** Options to spread when MCP bypass is enabled */
bypassOptions: Partial<Options>;
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* Build MCP-related options based on configuration.
* Centralizes the logic for determining permission modes and tool restrictions
* when MCP servers are configured.
*
* @param config - The SDK options config
* @returns Object with MCP permission settings to spread into final options
*/
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
// Determine if we should bypass permissions based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
return {
shouldRestrictTools,
// Only include bypass options when MCP is configured and auto-approve is enabled
bypassOptions: shouldBypassPermissions
? {
permissionMode: 'bypassPermissions' as const,
// Required flag when using bypassPermissions mode
allowDangerouslySkipPermissions: true,
}
: {},
// Include MCP servers if configured
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
};
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
* - If there's a custom systemPrompt, appends it to the preset
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
*/
function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: {
systemPrompt: SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>;
} = {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt;
}
return result;
}
/**
* System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
*/
export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
}
/** /**
* Options configuration for creating SDK options * Options configuration for creating SDK options
*/ */
@@ -260,31 +160,8 @@ export interface CreateSdkOptionsConfig {
type: 'json_schema'; type: 'json_schema';
schema: Record<string, unknown>; schema: Record<string, unknown>;
}; };
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Auto-approve MCP tool calls without permission prompts */
mcpAutoApproveTools?: boolean;
/** Allow unrestricted tools when MCP servers are enabled */
mcpUnrestrictedTools?: boolean;
} }
// Re-export MCP types from @automaker/types for convenience
export type {
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from '@automaker/types';
/** /**
* Create SDK options for spec generation * Create SDK options for spec generation
* *
@@ -292,15 +169,11 @@ export type {
* - Uses read-only tools for codebase analysis * - Uses read-only tools for codebase analysis
* - Extended turns for thorough exploration * - Extended turns for thorough exploration
* - Opus model by default (can be overridden) * - Opus model by default (can be overridden)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools // Override permissionMode - spec generation only needs read-only tools
@@ -311,7 +184,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
maxTurns: MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration], allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }), ...(config.outputFormat && { outputFormat: config.outputFormat }),
}; };
@@ -324,15 +197,11 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
* - Uses read-only tools (just needs to read the spec) * - Uses read-only tools (just needs to read the spec)
* - Quick turns since it's mostly JSON generation * - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed * - Sonnet model by default for speed
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools // Override permissionMode - feature generation only needs read-only tools
@@ -341,7 +210,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
maxTurns: MAX_TURNS.quick, maxTurns: MAX_TURNS.quick,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
}; };
} }
@@ -353,22 +222,18 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
* - Uses read-only tools for analysis * - Uses read-only tools for analysis
* - Standard turns to allow thorough codebase exploration and structured output generation * - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis * - Opus model by default for thorough analysis
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model), model: getModelForUseCase('suggestions', config.model),
maxTurns: MAX_TURNS.extended, maxTurns: MAX_TURNS.extended,
cwd: config.cwd, cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly], allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions, ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }), ...(config.outputFormat && { outputFormat: config.outputFormat }),
}; };
@@ -381,8 +246,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification * - Full tool access for code modification
* - Standard turns for interactive sessions * - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default * - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting * - Sandbox enabled for bash safety
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createChatOptions(config: CreateSdkOptionsConfig): Options { export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
@@ -391,30 +255,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Model priority: explicit model > session model > chat default // Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel; const effectiveModel = config.model || config.sessionModel;
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel), model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard, maxTurns: MAX_TURNS.standard,
cwd: config.cwd, cwd: config.cwd,
// Only restrict tools if no MCP servers configured or unrestricted is disabled allowedTools: [...TOOL_PRESETS.chat],
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), sandbox: {
// Apply MCP bypass options if configured enabled: true,
...mcpOptions.bypassOptions, autoAllowBashIfSandboxed: true,
...(config.enableSandboxMode && { },
sandbox: { ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
}; };
} }
@@ -425,37 +277,24 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation * - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation * - Extended turns for thorough feature implementation
* - Uses default model (can be overridden) * - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting * - Sandbox enabled for bash safety
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('auto', config.model), model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum, maxTurns: MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
// Only restrict tools if no MCP servers configured or unrestricted is disabled allowedTools: [...TOOL_PRESETS.fullAccess],
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), sandbox: {
// Apply MCP bypass options if configured enabled: true,
...mcpOptions.bypassOptions, autoAllowBashIfSandboxed: true,
...(config.enableSandboxMode && { },
sandbox: { ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
}; };
} }
@@ -463,7 +302,6 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
* Create custom SDK options with explicit configuration * Create custom SDK options with explicit configuration
* *
* Use this when the preset options don't fit your use case. * Use this when the preset options don't fit your use case.
* When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/ */
export function createCustomOptions( export function createCustomOptions(
config: CreateSdkOptionsConfig & { config: CreateSdkOptionsConfig & {
@@ -475,30 +313,14 @@ export function createCustomOptions(
// Validate working directory before creating options // Validate working directory before creating options
validateWorkingDirectory(config.cwd); validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
: mcpOptions.shouldRestrictTools
? [...TOOL_PRESETS.readOnly]
: undefined;
return { return {
...getBaseOptions(), ...getBaseOptions(),
model: getModelForUseCase('default', config.model), model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum, maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd, cwd: config.cwd,
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }), allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }), ...(config.sandbox && { sandbox: config.sandbox }),
// Apply MCP bypass options if configured ...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...mcpOptions.bypassOptions,
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }), ...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
}; };
} }

View File

@@ -20,9 +20,4 @@ export const {
lstat, lstat,
joinPath, joinPath,
resolvePath, resolvePath,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,
getPendingOperations,
getActiveOperations,
} = secureFs; } = secureFs;

View File

@@ -1,257 +0,0 @@
/**
* Helper utilities for loading settings and context file handling across different parts of the server
*/
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig } from '@automaker/types';
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
* Returns false if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[DescribeImage]')
* @returns Promise resolving to the autoLoadClaudeMd setting value
*/
export async function getAutoLoadClaudeMdSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.autoLoadClaudeMd !== undefined) {
console.log(
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
);
return projectSettings.autoLoadClaudeMd;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
throw error;
}
}
/**
* Get the enableSandboxMode setting from global settings.
* Returns false if settings service is not available.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the enableSandboxMode setting value
*/
export async function getEnableSandboxModeSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? true;
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.
*
* When autoLoadClaudeMd is true, the SDK handles CLAUDE.md loading via settingSources,
* so we need to exclude it from the manual context loading to avoid duplication.
* Other context files (CODE_QUALITY.md, CONVENTIONS.md, etc.) are preserved.
*
* @param contextResult - Result from loadContextFiles
* @param autoLoadClaudeMd - Whether SDK auto-loading is enabled
* @returns Filtered context prompt (empty string if no non-CLAUDE.md files)
*/
export function filterClaudeMdFromContext(
contextResult: ContextFilesResult,
autoLoadClaudeMd: boolean
): string {
// If autoLoadClaudeMd is disabled, return the original prompt unchanged
if (!autoLoadClaudeMd || contextResult.files.length === 0) {
return contextResult.formattedPrompt;
}
// Filter out CLAUDE.md (case-insensitive)
const nonClaudeFiles = contextResult.files.filter((f) => f.name.toLowerCase() !== 'claude.md');
// If all files were CLAUDE.md, return empty string
if (nonClaudeFiles.length === 0) {
return '';
}
// Rebuild prompt without CLAUDE.md using the same format as loadContextFiles
const formattedFiles = nonClaudeFiles.map((file) => formatContextFileEntry(file));
return `# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
${formattedFiles.join('\n\n---\n\n')}
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
`;
}
/**
* Format a single context file entry for the prompt
* (Matches the format used in @automaker/utils/context-loader.ts)
*/
function formatContextFileEntry(file: ContextFileInfo): string {
const header = `## ${file.name}`;
const pathInfo = `**Path:** \`${file.path}\``;
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}
/**
* Get enabled MCP servers from global settings, converted to SDK format.
* Returns an empty object if settings service is not available or no servers are configured.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
*/
export async function getMCPServersFromSettings(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<Record<string, McpServerConfig>> {
if (!settingsService) {
return {};
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const mcpServers = globalSettings.mcpServers || [];
// Filter to only enabled servers and convert to SDK format
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
if (enabledServers.length === 0) {
return {};
}
// Convert settings format to SDK format (keyed by name)
const sdkServers: Record<string, McpServerConfig> = {};
for (const server of enabledServers) {
sdkServers[server.name] = convertToSdkFormat(server);
}
console.log(
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
);
return sdkServers;
} catch (error) {
console.error(`${logPrefix} Failed to load MCP servers setting:`, error);
return {};
}
}
/**
* Get MCP permission settings from global settings.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to MCP permission settings
*/
export async function getMCPPermissionSettings(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
if (!settingsService) {
return defaults;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = {
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
};
console.log(
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load MCP permission settings:`, error);
return defaults;
}
}
/**
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
* Validates required fields and throws informative errors if missing.
*/
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
if (server.type === 'sse') {
if (!server.url) {
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
}
return {
type: 'sse',
url: server.url,
headers: server.headers,
};
}
if (server.type === 'http') {
if (!server.url) {
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
}
return {
type: 'http',
url: server.url,
headers: server.headers,
};
}
// Default to stdio
if (!server.command) {
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
}
return {
type: 'stdio',
command: server.command,
args: server.args,
env: server.env,
};
}

View File

@@ -7,6 +7,29 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { validatePath, PathNotAllowedError } from '@automaker/platform'; import { validatePath, PathNotAllowedError } from '@automaker/platform';
/**
* Custom error for invalid path type
*/
class InvalidPathTypeError extends Error {
constructor(paramName: string, expectedType: string, actualType: string) {
super(`Invalid type for '${paramName}': expected ${expectedType}, got ${actualType}`);
this.name = 'InvalidPathTypeError';
}
}
/**
* Validates that a value is a non-empty string suitable for path validation
*
* @param value - The value to check
* @param paramName - The parameter name for error messages
* @throws InvalidPathTypeError if value is not a valid string
*/
function assertValidPathString(value: unknown, paramName: string): asserts value is string {
if (typeof value !== 'string') {
throw new InvalidPathTypeError(paramName, 'string', typeof value);
}
}
/** /**
* Creates a middleware that validates specified path parameters in req.body * Creates a middleware that validates specified path parameters in req.body
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
@@ -27,7 +50,8 @@ export function validatePathParams(...paramNames: string[]) {
if (paramName.endsWith('?')) { if (paramName.endsWith('?')) {
const actualName = paramName.slice(0, -1); const actualName = paramName.slice(0, -1);
const value = req.body[actualName]; const value = req.body[actualName];
if (value) { if (value !== undefined && value !== null) {
assertValidPathString(value, actualName);
validatePath(value); validatePath(value);
} }
continue; continue;
@@ -37,17 +61,30 @@ export function validatePathParams(...paramNames: string[]) {
if (paramName.endsWith('[]')) { if (paramName.endsWith('[]')) {
const actualName = paramName.slice(0, -2); const actualName = paramName.slice(0, -2);
const values = req.body[actualName]; const values = req.body[actualName];
if (Array.isArray(values) && values.length > 0) {
for (const value of values) { // Skip if not provided or empty
validatePath(value); if (values === undefined || values === null) {
} continue;
}
// Validate that it's actually an array
if (!Array.isArray(values)) {
throw new InvalidPathTypeError(actualName, 'array', typeof values);
}
// Validate each element in the array
for (let i = 0; i < values.length; i++) {
const value = values[i];
assertValidPathString(value, `${actualName}[${i}]`);
validatePath(value);
} }
continue; continue;
} }
// Handle regular parameters // Handle regular parameters
const value = req.body[paramName]; const value = req.body[paramName];
if (value) { if (value !== undefined && value !== null) {
assertValidPathString(value, paramName);
validatePath(value); validatePath(value);
} }
} }
@@ -62,6 +99,14 @@ export function validatePathParams(...paramNames: string[]) {
return; return;
} }
if (error instanceof InvalidPathTypeError) {
res.status(400).json({
success: false,
error: error.message,
});
return;
}
// Re-throw unexpected errors // Re-throw unexpected errors
throw error; throw error;
} }

View File

@@ -36,44 +36,25 @@ export class ClaudeProvider extends BaseProvider {
} = options; } = options;
// Build Claude SDK options // Build Claude SDK options
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
// the provider is the final point where SDK options are constructed.
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const toolsToUse = allowedTools || defaultTools;
// Determine permission mode based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
const sdkOptions: Options = { const sdkOptions: Options = {
model, model,
systemPrompt, systemPrompt,
maxTurns, maxTurns,
cwd, cwd,
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) allowedTools: toolsToUse,
...(allowedTools && shouldRestrictTools && { allowedTools }), permissionMode: 'acceptEdits',
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), sandbox: {
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions enabled: true,
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', autoAllowBashIfSandboxed: true,
// Required when using bypassPermissions mode },
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
abortController, abortController,
// Resume existing SDK session if we have a session ID // Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0 ...(sdkSessionId && conversationHistory && conversationHistory.length > 0
? { resume: sdkSessionId } ? { resume: sdkSessionId }
: {}), : {}),
// Forward settingSources for CLAUDE.md file loading
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
// Forward MCP servers configuration
...(options.mcpServers && { mcpServers: options.mcpServers }),
}; };
// Build prompt payload // Build prompt payload
@@ -107,8 +88,7 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage; yield msg as ProviderMessage;
} }
} catch (error) { } catch (error) {
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error); console.error('[ClaudeProvider] executeQuery() error during execution:', error);
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
throw error; throw error;
} }
} }

View File

@@ -1,19 +1,39 @@
/** /**
* Shared types for AI model providers * Shared types for AI model providers
*
* Re-exports types from @automaker/types for consistency across the codebase.
*/ */
// Re-export all provider types from @automaker/types /**
export type { * Configuration for a provider instance
ProviderConfig, */
ConversationMessage, export interface ProviderConfig {
ExecuteOptions, apiKey?: string;
McpServerConfig, cliPath?: string;
McpStdioServerConfig, env?: Record<string, string>;
McpSSEServerConfig, }
McpHttpServerConfig,
} from '@automaker/types'; /**
* Message in conversation history
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string | Array<{ type: string; text?: string; source?: object }>;
}
/**
* Options for executing a query via a provider
*/
export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string;
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
}
/** /**
* Content block in a provider message (matches Claude SDK format) * Content block in a provider message (matches Claude SDK format)

View File

@@ -12,10 +12,6 @@ import { createHistoryHandler } from './routes/history.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createClearHandler } from './routes/clear.js'; import { createClearHandler } from './routes/clear.js';
import { createModelHandler } from './routes/model.js'; import { createModelHandler } from './routes/model.js';
import { createQueueAddHandler } from './routes/queue-add.js';
import { createQueueListHandler } from './routes/queue-list.js';
import { createQueueRemoveHandler } from './routes/queue-remove.js';
import { createQueueClearHandler } from './routes/queue-clear.js';
export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router { export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router {
const router = Router(); const router = Router();
@@ -31,15 +27,5 @@ export function createAgentRoutes(agentService: AgentService, _events: EventEmit
router.post('/clear', createClearHandler(agentService)); router.post('/clear', createClearHandler(agentService));
router.post('/model', createModelHandler(agentService)); router.post('/model', createModelHandler(agentService));
// Queue routes
router.post(
'/queue/add',
validatePathParams('imagePaths[]'),
createQueueAddHandler(agentService)
);
router.post('/queue/list', createQueueListHandler(agentService));
router.post('/queue/remove', createQueueRemoveHandler(agentService));
router.post('/queue/clear', createQueueClearHandler(agentService));
return router; return router;
} }

View File

@@ -1,34 +0,0 @@
/**
* POST /queue/add endpoint - Add a prompt to the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueAddHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, message, imagePaths, model } = req.body as {
sessionId: string;
message: string;
imagePaths?: string[];
model?: string;
};
if (!sessionId || !message) {
res.status(400).json({
success: false,
error: 'sessionId and message are required',
});
return;
}
const result = await agentService.addToQueue(sessionId, { message, imagePaths, model });
res.json(result);
} catch (error) {
logError(error, 'Add to queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/clear endpoint - Clear all prompts from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueClearHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = await agentService.clearQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Clear queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,29 +0,0 @@
/**
* POST /queue/list endpoint - List queued prompts
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueListHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId } = req.body as { sessionId: string };
if (!sessionId) {
res.status(400).json({
success: false,
error: 'sessionId is required',
});
return;
}
const result = agentService.getQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'List queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,32 +0,0 @@
/**
* POST /queue/remove endpoint - Remove a prompt from the queue
*/
import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createQueueRemoveHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, promptId } = req.body as {
sessionId: string;
promptId: string;
};
if (!sessionId || !promptId) {
res.status(400).json({
success: false,
error: 'sessionId and promptId are required',
});
return;
}
const result = await agentService.removeFromQueue(sessionId, promptId);
res.json(result);
} catch (error) {
logError(error, 'Remove from queue failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -19,16 +19,7 @@ export function createSendHandler(agentService: AgentService) {
model?: string; model?: string;
}; };
console.log('[Send Handler] Received request:', {
sessionId,
messageLength: message?.length,
workingDirectory,
imageCount: imagePaths?.length || 0,
model,
});
if (!sessionId || !message) { if (!sessionId || !message) {
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'sessionId and message are required', error: 'sessionId and message are required',
@@ -36,8 +27,6 @@ export function createSendHandler(agentService: AgentService) {
return; return;
} }
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
// Start the message processing (don't await - it streams via WebSocket) // Start the message processing (don't await - it streams via WebSocket)
agentService agentService
.sendMessage({ .sendMessage({
@@ -48,16 +37,12 @@ export function createSendHandler(agentService: AgentService) {
model, model,
}) })
.catch((error) => { .catch((error) => {
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
logError(error, 'Send message failed (background)'); logError(error, 'Send message failed (background)');
}); });
console.log('[Send Handler] Returning immediate response to client');
// Return immediately - responses come via WebSocket // Return immediately - responses come via WebSocket
res.json({ success: true, message: 'Message sent' }); res.json({ success: true, message: 'Message sent' });
} catch (error) { } catch (error) {
console.error('[Send Handler] ERROR: Synchronous error:', error);
logError(error, 'Send message failed'); logError(error, 'Send message failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -10,8 +10,6 @@ import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js'; import { logAuthStatus } from './common.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform'; import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -21,8 +19,7 @@ export async function generateFeaturesFromSpec(
projectPath: string, projectPath: string,
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController,
maxFeatures?: number, maxFeatures?: number
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES; const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug('========== generateFeaturesFromSpec() started =========='); logger.debug('========== generateFeaturesFromSpec() started ==========');
@@ -94,17 +91,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath, projectPath: projectPath,
}); });
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[FeatureGeneration]'
);
const options = createFeatureGenerationOptions({ const options = createFeatureGenerationOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
}); });
logger.debug('SDK Options:', JSON.stringify(options, null, 2)); logger.debug('SDK Options:', JSON.stringify(options, null, 2));

View File

@@ -17,8 +17,6 @@ import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js'; import { logAuthStatus } from './common.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
@@ -29,8 +27,7 @@ export async function generateSpec(
abortController: AbortController, abortController: AbortController,
generateFeatures?: boolean, generateFeatures?: boolean,
analyzeProject?: boolean, analyzeProject?: boolean,
maxFeatures?: number, maxFeatures?: number
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
logger.info('========== generateSpec() started =========='); logger.info('========== generateSpec() started ==========');
logger.info('projectPath:', projectPath); logger.info('projectPath:', projectPath);
@@ -86,17 +83,9 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n', content: 'Starting spec generation...\n',
}); });
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecRegeneration]'
);
const options = createSpecGenerationOptions({ const options = createSpecGenerationOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
outputFormat: { outputFormat: {
type: 'json_schema', type: 'json_schema',
schema: specOutputSchema, schema: specOutputSchema,
@@ -280,13 +269,7 @@ ${getStructuredSpecPromptInstruction()}`;
// Create a new abort controller for feature generation // Create a new abort controller for feature generation
const featureAbortController = new AbortController(); const featureAbortController = new AbortController();
try { try {
await generateFeaturesFromSpec( await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
projectPath,
events,
featureAbortController,
maxFeatures,
settingsService
);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) { } catch (featureError) {
logger.error('Feature generation failed:', featureError); logger.error('Feature generation failed:', featureError);

View File

@@ -9,17 +9,13 @@ import { createGenerateHandler } from './routes/generate.js';
import { createGenerateFeaturesHandler } from './routes/generate-features.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSpecRegenerationRoutes( export function createSpecRegenerationRoutes(events: EventEmitter): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post('/create', createCreateHandler(events)); router.post('/create', createCreateHandler(events));
router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate', createGenerateHandler(events));
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events));
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -13,14 +13,10 @@ import {
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { generateFeaturesFromSpec } from '../generate-features-from-spec.js'; import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
export function createGenerateFeaturesHandler( export function createGenerateFeaturesHandler(events: EventEmitter) {
events: EventEmitter,
settingsService?: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate-features endpoint called =========='); logger.info('========== /generate-features endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2)); logger.debug('Request body:', JSON.stringify(req.body, null, 2));
@@ -53,7 +49,7 @@ export function createGenerateFeaturesHandler(
setRunningState(true, abortController); setRunningState(true, abortController);
logger.info('Starting background feature generation task...'); logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures)
.catch((error) => { .catch((error) => {
logError(error, 'Feature generation failed with error'); logError(error, 'Feature generation failed with error');
events.emit('spec-regeneration:event', { events.emit('spec-regeneration:event', {

View File

@@ -13,11 +13,10 @@ import {
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { generateSpec } from '../generate-spec.js'; import { generateSpec } from '../generate-spec.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /generate endpoint called =========='); logger.info('========== /generate endpoint called ==========');
logger.debug('Request body:', JSON.stringify(req.body, null, 2)); logger.debug('Request body:', JSON.stringify(req.body, null, 2));
@@ -68,8 +67,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
abortController, abortController,
generateFeatures, generateFeatures,
analyzeProject, analyzeProject,
maxFeatures, maxFeatures
settingsService
) )
.catch((error) => { .catch((error) => {
logError(error, 'Generation failed with error'); logError(error, 'Generation failed with error');

View File

@@ -1,39 +0,0 @@
/**
* Common utilities for backlog plan routes
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('BacklogPlan');
// State for tracking running generation
let isRunning = false;
let currentAbortController: AbortController | null = null;
export function getBacklogPlanStatus(): { isRunning: boolean } {
return { isRunning };
}
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
isRunning = running;
if (abortController !== undefined) {
currentAbortController = abortController;
}
}
export function getAbortController(): AbortController | null {
return currentAbortController;
}
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error));
}
export { logger };

View File

@@ -1,217 +0,0 @@
/**
* Generate backlog plan using Claude AI
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logger, setRunningState, getErrorMessage } from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
/**
* Format features for the AI prompt
*/
function formatFeaturesForPrompt(features: Feature[]): string {
if (features.length === 0) {
return 'No features in backlog yet.';
}
return features
.map((f) => {
const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : '';
const priority = f.priority !== undefined ? `Priority: ${f.priority}` : '';
return `- ID: ${f.id}
Title: ${f.title || 'Untitled'}
Description: ${f.description}
Category: ${f.category}
Status: ${f.status || 'backlog'}
${priority}
${deps}`.trim();
})
.join('\n\n');
}
/**
* Parse the AI response into a BacklogPlanResult
*/
function parsePlanResponse(response: string): BacklogPlanResult {
try {
// Try to extract JSON from the response
const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/);
if (jsonMatch) {
return JSON.parse(jsonMatch[1]);
}
// Try to parse the whole response as JSON
return JSON.parse(response);
} catch {
// If parsing fails, return an empty result
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
return {
changes: [],
summary: 'Failed to parse AI response',
dependencyUpdates: [],
};
}
}
/**
* Generate a backlog modification plan based on user prompt
*/
export async function generateBacklogPlan(
projectPath: string,
prompt: string,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
model?: string
): Promise<BacklogPlanResult> {
try {
// Load current features
const features = await featureLoader.getAll(projectPath);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Loaded ${features.length} features from backlog`,
});
// Build the system prompt
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
You will be given the current list of features and a user request to modify the backlog.
IMPORTANT CONTEXT (automatically injected):
- Remember to update the dependency graph if deleting existing features
- Remember to define dependencies on new features hooked into relevant existing ones
- Maintain dependency graph integrity (no orphaned dependencies)
- When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with:
1. Features to ADD (include title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId)
4. A summary of the changes
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
Respond with ONLY a JSON object in this exact format:
\`\`\`json
{
"changes": [
{
"type": "add",
"feature": {
"title": "Feature title",
"description": "Feature description",
"category": "Category name",
"dependencies": ["existing-feature-id"],
"priority": 1
},
"reason": "Why this feature should be added"
},
{
"type": "update",
"featureId": "existing-feature-id",
"feature": {
"title": "Updated title"
},
"reason": "Why this feature should be updated"
},
{
"type": "delete",
"featureId": "feature-id-to-delete",
"reason": "Why this feature should be deleted"
}
],
"summary": "Brief overview of all proposed changes",
"dependencyUpdates": [
{
"featureId": "feature-that-depended-on-deleted",
"removedDependencies": ["deleted-feature-id"],
"addedDependencies": []
}
]
}
\`\`\``;
// Build the user prompt
const userPrompt = `Current Features in Backlog:
${formatFeaturesForPrompt(features)}
---
User Request: ${prompt}
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: 'Generating plan with AI...',
});
// Get the model to use
const effectiveModel = model || 'sonnet';
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Get autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: userPrompt,
model: effectiveModel,
cwd: projectPath,
systemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
});
let responseText = '';
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
}
}
// Parse the response
const result = parsePlanResponse(responseText);
events.emit('backlog-plan:event', {
type: 'backlog_plan_complete',
result,
});
return result;
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error('[BacklogPlan] Generation failed:', errorMessage);
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: errorMessage,
});
throw error;
} finally {
setRunningState(false, null);
}
}

View File

@@ -1,30 +0,0 @@
/**
* Backlog Plan routes - HTTP API for AI-assisted backlog modification
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
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 type { SettingsService } from '../../services/settings-service.js';
export function createBacklogPlanRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router();
router.post(
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler());
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
return router;
}

View File

@@ -1,147 +0,0 @@
/**
* POST /apply endpoint - Apply a backlog plan
*/
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';
const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, plan } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!plan || !plan.changes) {
res.status(400).json({ success: false, error: 'plan with changes required' });
return;
}
const appliedChanges: string[] = [];
// Load current features for dependency validation
const allFeatures = await featureLoader.getAll(projectPath);
const featureMap = new Map(allFeatures.map((f) => [f.id, f]));
// Process changes in order: deletes first, then adds, then updates
// This ensures we can remove dependencies before they cause issues
// 1. First pass: Handle deletes
const deletions = plan.changes.filter((c) => c.type === 'delete');
for (const change of deletions) {
if (!change.featureId) continue;
try {
// Before deleting, update any features that depend on this one
for (const feature of allFeatures) {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);
}
}
// Now delete the feature
const deleted = await featureLoader.delete(projectPath, change.featureId);
if (deleted) {
appliedChanges.push(`deleted:${change.featureId}`);
featureMap.delete(change.featureId);
logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to delete ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 2. Second pass: Handle adds
const additions = plan.changes.filter((c) => c.type === 'add');
for (const change of additions) {
if (!change.feature) continue;
try {
// Create the new feature
const newFeature = await featureLoader.create(projectPath, {
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
});
appliedChanges.push(`added:${newFeature.id}`);
featureMap.set(newFeature.id, newFeature);
logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`);
} catch (error) {
logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error));
}
}
// 3. Third pass: Handle updates
const updates = plan.changes.filter((c) => c.type === 'update');
for (const change of updates) {
if (!change.featureId || !change.feature) continue;
try {
const updated = await featureLoader.update(projectPath, change.featureId, change.feature);
appliedChanges.push(`updated:${change.featureId}`);
featureMap.set(change.featureId, updated);
logger.info(`[BacklogPlan] Updated feature ${change.featureId}`);
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update ${change.featureId}:`,
getErrorMessage(error)
);
}
}
// 4. Apply dependency updates from the plan
if (plan.dependencyUpdates) {
for (const depUpdate of plan.dependencyUpdates) {
try {
const feature = featureMap.get(depUpdate.featureId);
if (feature) {
const currentDeps = feature.dependencies || [];
const newDeps = currentDeps
.filter((d) => !depUpdate.removedDependencies.includes(d))
.concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d)));
await featureLoader.update(projectPath, depUpdate.featureId, {
dependencies: newDeps,
});
logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`);
}
} catch (error) {
logger.error(
`[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`,
getErrorMessage(error)
);
}
}
}
res.json({
success: true,
appliedChanges,
});
} catch (error) {
logError(error, 'Apply backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,62 +0,0 @@
/**
* POST /generate endpoint - Generate a backlog plan
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateBacklogPlan } from '../generate-plan.js';
import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prompt, model } = req.body as {
projectPath: string;
prompt: string;
model?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
if (!prompt) {
res.status(400).json({ success: false, error: 'prompt required' });
return;
}
const { isRunning } = getBacklogPlanStatus();
if (isRunning) {
res.json({
success: false,
error: 'Backlog plan generation is already running',
});
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
logError(error, 'Generate backlog plan failed (background)');
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: getErrorMessage(error),
});
})
.finally(() => {
setRunningState(false, null);
});
res.json({ success: true });
} catch (error) {
logError(error, 'Generate backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,18 +0,0 @@
/**
* GET /status endpoint - Get backlog plan generation status
*/
import type { Request, Response } from 'express';
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const status = getBacklogPlanStatus();
res.json({ success: true, ...status });
} catch (error) {
logError(error, 'Get backlog plan status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,22 +0,0 @@
/**
* POST /stop endpoint - Stop the current backlog plan generation
*/
import type { Request, Response } from 'express';
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const abortController = getAbortController();
if (abortController) {
abortController.abort();
setRunningState(false, null);
}
res.json({ success: true });
} catch (error) {
logError(error, 'Stop backlog plan failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -8,19 +8,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { createDescribeImageHandler } from './routes/describe-image.js'; import { createDescribeImageHandler } from './routes/describe-image.js';
import { createDescribeFileHandler } from './routes/describe-file.js'; import { createDescribeFileHandler } from './routes/describe-file.js';
import type { SettingsService } from '../../services/settings-service.js';
/** /**
* Create the context router * Create the context router
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express router with context endpoints * @returns Express router with context endpoints
*/ */
export function createContextRoutes(settingsService?: SettingsService): Router { export function createContextRoutes(): Router {
const router = Router(); const router = Router();
router.post('/describe-image', createDescribeImageHandler(settingsService)); router.post('/describe-image', createDescribeImageHandler());
router.post('/describe-file', createDescribeFileHandler(settingsService)); router.post('/describe-file', createDescribeFileHandler());
return router; return router;
} }

View File

@@ -17,8 +17,6 @@ import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js'; import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js'; import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile'); const logger = createLogger('DescribeFile');
@@ -74,12 +72,9 @@ async function extractTextFromStream(
/** /**
* Create the describe-file request handler * Create the describe-file request handler
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for file description * @returns Express request handler for file description
*/ */
export function createDescribeFileHandler( export function createDescribeFileHandler(): (req: Request, res: Response) => Promise<void> {
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { filePath } = req.body as DescribeFileRequestBody; const { filePath } = req.body as DescribeFileRequestBody;
@@ -170,13 +165,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
// Use the file's directory as the working directory // Use the file's directory as the working directory
const cwd = path.dirname(resolvedPath); const cwd = path.dirname(resolvedPath);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeFile]'
);
// Use centralized SDK options with proper cwd validation // Use centralized SDK options with proper cwd validation
// No tools needed since we're passing file content directly // No tools needed since we're passing file content directly
const sdkOptions = createCustomOptions({ const sdkOptions = createCustomOptions({
@@ -184,7 +172,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
model: CLAUDE_MODEL_MAP.haiku, model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
}); });

View File

@@ -17,8 +17,6 @@ import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js'; import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage'); const logger = createLogger('DescribeImage');
@@ -228,12 +226,9 @@ async function extractTextFromStream(
* Uses Claude SDK query with multi-part content blocks to include the image (base64), * Uses Claude SDK query with multi-part content blocks to include the image (base64),
* matching the agent runner behavior. * matching the agent runner behavior.
* *
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
* @returns Express request handler for image description * @returns Express request handler for image description
*/ */
export function createDescribeImageHandler( export function createDescribeImageHandler(): (req: Request, res: Response) => Promise<void> {
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -330,20 +325,12 @@ export function createDescribeImageHandler(
const cwd = path.dirname(actualPath); const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`); logger.info(`[${requestId}] Using cwd=${cwd}`);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeImage]'
);
// Use the same centralized option builder used across the server (validates cwd) // Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({ const sdkOptions = createCustomOptions({
cwd, cwd,
model: CLAUDE_MODEL_MAP.haiku, model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
}); });

View File

@@ -16,12 +16,8 @@ import {
createDeleteValidationHandler, createDeleteValidationHandler,
createMarkViewedHandler, createMarkViewedHandler,
} from './routes/validation-endpoints.js'; } from './routes/validation-endpoints.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createGitHubRoutes( export function createGitHubRoutes(events: EventEmitter): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
@@ -30,7 +26,7 @@ export function createGitHubRoutes(
router.post( router.post(
'/validate-issue', '/validate-issue',
validatePathParams('projectPath'), validatePathParams('projectPath'),
createValidateIssueHandler(events, settingsService) createValidateIssueHandler(events)
); );
// Validation management endpoints // Validation management endpoints

View File

@@ -23,8 +23,6 @@ import {
logError, logError,
logger, logger,
} from './validation-common.js'; } from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/** Valid model values for validation */ /** Valid model values for validation */
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const; const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
@@ -56,8 +54,7 @@ async function runValidation(
issueLabels: string[] | undefined, issueLabels: string[] | undefined,
model: AgentModel, model: AgentModel,
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
// Emit start event // Emit start event
const startEvent: IssueValidationEvent = { const startEvent: IssueValidationEvent = {
@@ -79,20 +76,12 @@ async function runValidation(
// Build the prompt // Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels); const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Create SDK options with structured output and abort controller // Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({ const options = createSuggestionsOptions({
cwd: projectPath, cwd: projectPath,
model, model,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController, abortController,
autoLoadClaudeMd,
outputFormat: { outputFormat: {
type: 'json_schema', type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>, schema: issueValidationSchema as Record<string, unknown>,
@@ -201,10 +190,7 @@ async function runValidation(
* - System prompt guiding the validation process * - System prompt guiding the validation process
* - Async execution with event emission * - Async execution with event emission
*/ */
export function createValidateIssueHandler( export function createValidateIssueHandler(events: EventEmitter) {
events: EventEmitter,
settingsService?: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { const {
@@ -270,8 +256,7 @@ export function createValidateIssueHandler(
issueLabels, issueLabels,
model, model,
events, events,
abortController, abortController
settingsService
) )
.catch((error) => { .catch((error) => {
// Error is already handled inside runValidation (event emitted) // Error is already handled inside runValidation (event emitted)

View File

@@ -1,20 +0,0 @@
/**
* Common utilities for MCP routes
*/
/**
* Extract error message from unknown error
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Log error with prefix
*/
export function logError(error: unknown, message: string): void {
console.error(`[MCP] ${message}:`, error);
}

View File

@@ -1,36 +0,0 @@
/**
* MCP routes - HTTP API for testing MCP servers
*
* Provides endpoints for:
* - Testing MCP server connections
* - Listing available tools from MCP servers
*
* Mounted at /api/mcp in the main server.
*/
import { Router } from 'express';
import type { MCPTestService } from '../../services/mcp-test-service.js';
import { createTestServerHandler } from './routes/test-server.js';
import { createListToolsHandler } from './routes/list-tools.js';
/**
* Create MCP router with all endpoints
*
* Endpoints:
* - POST /test - Test MCP server connection
* - POST /tools - List tools from MCP server
*
* @param mcpTestService - Instance of MCPTestService for testing connections
* @returns Express Router configured with all MCP endpoints
*/
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
const router = Router();
// Test MCP server connection
router.post('/test', createTestServerHandler(mcpTestService));
// List tools from MCP server
router.post('/tools', createListToolsHandler(mcpTestService));
return router;
}

View File

@@ -1,57 +0,0 @@
/**
* POST /api/mcp/tools - List tools for an MCP server
*
* Lists available tools for an MCP server.
* Similar to test but focused on tool discovery.
*
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
* arbitrary serverConfig to prevent drive-by command execution attacks.
* Users must explicitly save a server config through the UI before testing.
*
* Request body:
* { serverId: string } - Get tools by server ID from settings
*
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
*/
import type { Request, Response } from 'express';
import type { MCPTestService } from '../../../services/mcp-test-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ListToolsRequest {
serverId: string;
}
/**
* Create handler factory for POST /api/mcp/tools
*/
export function createListToolsHandler(mcpTestService: MCPTestService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as ListToolsRequest;
if (!body.serverId || typeof body.serverId !== 'string') {
res.status(400).json({
success: false,
error: 'serverId is required',
});
return;
}
const result = await mcpTestService.testServerById(body.serverId);
// Return only tool-related information
res.json({
success: result.success,
tools: result.tools,
error: result.error,
});
} catch (error) {
logError(error, 'List tools failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,50 +0,0 @@
/**
* POST /api/mcp/test - Test MCP server connection and list tools
*
* Tests connection to an MCP server and returns available tools.
*
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
* arbitrary serverConfig to prevent drive-by command execution attacks.
* Users must explicitly save a server config through the UI before testing.
*
* Request body:
* { serverId: string } - Test server by ID from settings
*
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
*/
import type { Request, Response } from 'express';
import type { MCPTestService } from '../../../services/mcp-test-service.js';
import { getErrorMessage, logError } from '../common.js';
interface TestServerRequest {
serverId: string;
}
/**
* Create handler factory for POST /api/mcp/test
*/
export function createTestServerHandler(mcpTestService: MCPTestService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as TestServerRequest;
if (!body.serverId || typeof body.serverId !== 'string') {
res.status(400).json({
success: false,
error: 'serverId is required',
});
return;
}
const result = await mcpTestService.testServerById(body.serverId);
res.json(result);
} catch (error) {
logError(error, 'Test server failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,21 +0,0 @@
/**
* Common utilities for pipeline routes
*
* Provides logger and error handling utilities shared across all pipeline endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for pipeline-related operations */
export const logger = createLogger('Pipeline');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -1,77 +0,0 @@
/**
* Pipeline routes - HTTP API for pipeline configuration management
*
* Provides endpoints for:
* - Getting pipeline configuration
* - Saving pipeline configuration
* - Adding, updating, deleting, and reordering pipeline steps
*
* All endpoints use handler factories that receive the PipelineService instance.
* Mounted at /api/pipeline in the main server.
*/
import { Router } from 'express';
import type { PipelineService } from '../../services/pipeline-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGetConfigHandler } from './routes/get-config.js';
import { createSaveConfigHandler } from './routes/save-config.js';
import { createAddStepHandler } from './routes/add-step.js';
import { createUpdateStepHandler } from './routes/update-step.js';
import { createDeleteStepHandler } from './routes/delete-step.js';
import { createReorderStepsHandler } from './routes/reorder-steps.js';
/**
* Create pipeline router with all endpoints
*
* Endpoints:
* - POST /config - Get pipeline configuration
* - POST /config/save - Save entire pipeline configuration
* - POST /steps/add - Add a new pipeline step
* - POST /steps/update - Update an existing pipeline step
* - POST /steps/delete - Delete a pipeline step
* - POST /steps/reorder - Reorder pipeline steps
*
* @param pipelineService - Instance of PipelineService for file I/O
* @returns Express Router configured with all pipeline endpoints
*/
export function createPipelineRoutes(pipelineService: PipelineService): Router {
const router = Router();
// Get pipeline configuration
router.post(
'/config',
validatePathParams('projectPath'),
createGetConfigHandler(pipelineService)
);
// Save entire pipeline configuration
router.post(
'/config/save',
validatePathParams('projectPath'),
createSaveConfigHandler(pipelineService)
);
// Pipeline step operations
router.post(
'/steps/add',
validatePathParams('projectPath'),
createAddStepHandler(pipelineService)
);
router.post(
'/steps/update',
validatePathParams('projectPath'),
createUpdateStepHandler(pipelineService)
);
router.post(
'/steps/delete',
validatePathParams('projectPath'),
createDeleteStepHandler(pipelineService)
);
router.post(
'/steps/reorder',
validatePathParams('projectPath'),
createReorderStepsHandler(pipelineService)
);
return router;
}

View File

@@ -1,54 +0,0 @@
/**
* POST /api/pipeline/steps/add - Add a new pipeline step
*
* Adds a new step to the pipeline configuration.
*
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createAddStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, step } = req.body as {
projectPath: string;
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!step) {
res.status(400).json({ success: false, error: 'step is required' });
return;
}
if (!step.name) {
res.status(400).json({ success: false, error: 'step.name is required' });
return;
}
if (step.instructions === undefined) {
res.status(400).json({ success: false, error: 'step.instructions is required' });
return;
}
const newStep = await pipelineService.addStep(projectPath, step);
res.json({
success: true,
step: newStep,
});
} catch (error) {
logError(error, 'Add pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /api/pipeline/steps/delete - Delete a pipeline step
*
* Removes a step from the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId } = req.body as {
projectPath: string;
stepId: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
await pipelineService.deleteStep(projectPath, stepId);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,35 +0,0 @@
/**
* POST /api/pipeline/config - Get pipeline configuration
*
* Returns the pipeline configuration for a project.
*
* Request body: { projectPath: string }
* Response: { success: true, config: PipelineConfig }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const config = await pipelineService.getPipelineConfig(projectPath);
res.json({
success: true,
config,
});
} catch (error) {
logError(error, 'Get pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,42 +0,0 @@
/**
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
*
* Reorders the steps in the pipeline configuration.
*
* Request body: { projectPath: string, stepIds: string[] }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createReorderStepsHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepIds } = req.body as {
projectPath: string;
stepIds: string[];
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepIds || !Array.isArray(stepIds)) {
res.status(400).json({ success: false, error: 'stepIds array is required' });
return;
}
await pipelineService.reorderSteps(projectPath, stepIds);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Reorder pipeline steps failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,43 +0,0 @@
/**
* POST /api/pipeline/config/save - Save entire pipeline configuration
*
* Saves the complete pipeline configuration for a project.
*
* Request body: { projectPath: string, config: PipelineConfig }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineConfig } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createSaveConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, config } = req.body as {
projectPath: string;
config: PipelineConfig;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!config) {
res.status(400).json({ success: false, error: 'config is required' });
return;
}
await pipelineService.savePipelineConfig(projectPath, config);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Save pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,50 +0,0 @@
/**
* POST /api/pipeline/steps/update - Update an existing pipeline step
*
* Updates a step in the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createUpdateStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId, updates } = req.body as {
projectPath: string;
stepId: string;
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
if (!updates || Object.keys(updates).length === 0) {
res.status(400).json({ success: false, error: 'updates is required' });
return;
}
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
res.json({
success: true,
step: updatedStep,
});
} catch (error) {
logError(error, 'Update pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,7 +10,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js'; import { logError } from '../common.js';
/** /**
* Create handler factory for GET /api/settings/credentials * Create handler factory for GET /api/settings/credentials
@@ -29,7 +29,7 @@ export function createGetCredentialsHandler(settingsService: SettingsService) {
}); });
} catch (error) { } catch (error) {
logError(error, 'Get credentials failed'); logError(error, 'Get credentials failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: 'Failed to retrieve credentials' });
} }
}; };
} }

View File

@@ -11,7 +11,71 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js'; import type { SettingsService } from '../../../services/settings-service.js';
import type { Credentials } from '../../../types/settings.js'; import type { Credentials } from '../../../types/settings.js';
import { getErrorMessage, logError } from '../common.js'; import { logError } from '../common.js';
/** Maximum allowed length for API keys to prevent abuse */
const MAX_API_KEY_LENGTH = 512;
/** Known API key provider names that are valid */
const VALID_API_KEY_PROVIDERS = ['anthropic', 'google', 'openai'] as const;
/**
* Validates that the provided updates object has the correct structure
* and all apiKeys values are strings within acceptable length limits.
*
* @param updates - The partial credentials update object to validate
* @returns An error message if validation fails, or null if valid
*/
function validateCredentialsUpdate(updates: unknown): string | null {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return 'Invalid request body - expected credentials object';
}
const obj = updates as Record<string, unknown>;
// If apiKeys is provided, validate its structure
if ('apiKeys' in obj) {
const apiKeys = obj.apiKeys;
if (apiKeys === null || apiKeys === undefined) {
// Allow null/undefined to clear
return null;
}
if (typeof apiKeys !== 'object' || Array.isArray(apiKeys)) {
return 'Invalid apiKeys - expected object';
}
const keysObj = apiKeys as Record<string, unknown>;
// Validate each provided API key
for (const [provider, value] of Object.entries(keysObj)) {
// Check provider name is valid
if (!VALID_API_KEY_PROVIDERS.includes(provider as (typeof VALID_API_KEY_PROVIDERS)[number])) {
return `Invalid API key provider: ${provider}. Valid providers: ${VALID_API_KEY_PROVIDERS.join(', ')}`;
}
// Check value is a string
if (typeof value !== 'string') {
return `Invalid API key for ${provider} - expected string`;
}
// Check length limit
if (value.length > MAX_API_KEY_LENGTH) {
return `API key for ${provider} exceeds maximum length of ${MAX_API_KEY_LENGTH} characters`;
}
}
}
// Validate version if provided
if ('version' in obj && obj.version !== undefined) {
if (typeof obj.version !== 'number' || !Number.isInteger(obj.version) || obj.version < 0) {
return 'Invalid version - expected non-negative integer';
}
}
return null;
}
/** /**
* Create handler factory for PUT /api/settings/credentials * Create handler factory for PUT /api/settings/credentials
@@ -22,16 +86,19 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateCredentialsHandler(settingsService: SettingsService) { export function createUpdateCredentialsHandler(settingsService: SettingsService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const updates = req.body as Partial<Credentials>; // Validate the request body before type assertion
const validationError = validateCredentialsUpdate(req.body);
if (!updates || typeof updates !== 'object') { if (validationError) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Invalid request body - expected credentials object', error: validationError,
}); });
return; return;
} }
// Safe to cast after validation
const updates = req.body as Partial<Credentials>;
await settingsService.updateCredentials(updates); await settingsService.updateCredentials(updates);
// Return masked credentials for confirmation // Return masked credentials for confirmation
@@ -43,7 +110,7 @@ export function createUpdateCredentialsHandler(settingsService: SettingsService)
}); });
} catch (error) { } catch (error) {
logError(error, 'Update credentials failed'); logError(error, 'Update credentials failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: 'Failed to update credentials' });
} }
}; };
} }

View File

@@ -9,6 +9,17 @@ import { getErrorMessage as getErrorMessageShared, createLogError } from '../com
const logger = createLogger('Setup'); const logger = createLogger('Setup');
/**
* Escapes special regex characters in a string to prevent regex injection.
* This ensures user input can be safely used in RegExp constructors.
*
* @param str - The string to escape
* @returns The escaped string safe for use in RegExp
*/
export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Storage for API keys (in-memory cache) - private // Storage for API keys (in-memory cache) - private
const apiKeys: Record<string, string> = {}; const apiKeys: Record<string, string> = {};
@@ -33,6 +44,32 @@ export function getAllApiKeys(): Record<string, string> {
return { ...apiKeys }; return { ...apiKeys };
} }
/**
* Escape a value for safe inclusion in a .env file.
* Handles special characters like quotes, newlines, dollar signs, and backslashes.
* Returns a properly quoted string if needed.
*/
function escapeEnvValue(value: string): string {
// Check if the value contains any characters that require quoting
const requiresQuoting = /[\s"'$`\\#\n\r]/.test(value) || value.includes('=');
if (!requiresQuoting) {
return value;
}
// Use double quotes and escape special characters within
// Escape backslashes first to avoid double-escaping
let escaped = value
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\$/g, '\\$') // Escape dollar signs (prevents variable expansion)
.replace(/`/g, '\\`') // Escape backticks
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r'); // Escape carriage returns
return `"${escaped}"`;
}
/** /**
* Helper to persist API keys to .env file * Helper to persist API keys to .env file
*/ */
@@ -47,21 +84,24 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// .env file doesn't exist, we'll create it // .env file doesn't exist, we'll create it
} }
// Parse existing env content // Escape the value for safe .env file storage
const escapedValue = escapeEnvValue(value);
// Parse existing env content - match key with optional quoted values
const lines = envContent.split('\n'); const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`); const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
let found = false; let found = false;
const newLines = lines.map((line) => { const newLines = lines.map((line) => {
if (keyRegex.test(line)) { if (keyRegex.test(line)) {
found = true; found = true;
return `${key}=${value}`; return `${key}=${escapedValue}`;
} }
return line; return line;
}); });
if (!found) { if (!found) {
// Add the key at the end // Add the key at the end
newLines.push(`${key}=${value}`); newLines.push(`${key}=${escapedValue}`);
} }
await fs.writeFile(envPath, newLines.join('\n')); await fs.writeFile(envPath, newLines.join('\n'));

View File

@@ -3,7 +3,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getApiKey, getErrorMessage, logError } from '../common.js'; import { getApiKey, logError } from '../common.js';
export function createApiKeysHandler() { export function createApiKeysHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (_req: Request, res: Response): Promise<void> => {
@@ -14,7 +14,7 @@ export function createApiKeysHandler() {
}); });
} catch (error) { } catch (error) {
logError(error, 'Get API keys failed'); logError(error, 'Get API keys failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: 'Failed to retrieve API keys status' });
} }
}; };
} }

View File

@@ -11,7 +11,7 @@ const logger = createLogger('Setup');
// In-memory storage reference (imported from common.ts pattern) // In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function // We need to modify common.ts to export a deleteApiKey function
import { setApiKey } from '../common.js'; import { setApiKey, escapeRegExp } from '../common.js';
/** /**
* Remove an API key from the .env file * Remove an API key from the .env file
@@ -30,7 +30,7 @@ async function removeApiKeyFromEnv(key: string): Promise<void> {
// Parse existing env content and remove the key // Parse existing env content and remove the key
const lines = envContent.split('\n'); const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`); const keyRegex = new RegExp(`^${escapeRegExp(key)}=`);
const newLines = lines.filter((line) => !keyRegex.test(line)); const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end // Remove empty lines at the end
@@ -68,9 +68,10 @@ export function createDeleteApiKeyHandler() {
const envKey = envKeyMap[provider]; const envKey = envKeyMap[provider];
if (!envKey) { if (!envKey) {
logger.warn(`[Setup] Unknown provider requested for deletion: ${provider}`);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: `Unknown provider: ${provider}. Only anthropic is supported.`, error: 'Unknown provider. Only anthropic is supported.',
}); });
return; return;
} }
@@ -94,7 +95,7 @@ export function createDeleteApiKeyHandler() {
logger.error('[Setup] Delete API key error:', error); logger.error('[Setup] Delete API key error:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delete API key', error: 'Failed to delete API key',
}); });
} }
}; };

View File

@@ -3,7 +3,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js'; import { setApiKey, persistApiKeyToEnv, logError } from '../common.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
const logger = createLogger('Setup'); const logger = createLogger('Setup');
@@ -30,9 +30,10 @@ export function createStoreApiKeyHandler() {
await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey);
logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY');
} else { } else {
logger.warn(`[Setup] Unsupported provider requested: ${provider}`);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: `Unsupported provider: ${provider}. Only anthropic is supported.`, error: 'Unsupported provider. Only anthropic is supported.',
}); });
return; return;
} }
@@ -40,7 +41,7 @@ export function createStoreApiKeyHandler() {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
logError(error, 'Store API key failed'); logError(error, 'Store API key failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: 'Failed to store API key' });
} }
}; };
} }

View File

@@ -10,6 +10,41 @@ import { getApiKey } from '../common.js';
const logger = createLogger('Setup'); const logger = createLogger('Setup');
/**
* Simple mutex implementation to prevent race conditions when
* modifying process.env during concurrent verification requests.
*
* The Claude Agent SDK reads ANTHROPIC_API_KEY from process.env,
* so we must temporarily modify it for verification. This mutex
* ensures only one verification runs at a time.
*/
class VerificationMutex {
private locked = false;
private queue: Array<() => void> = [];
async acquire(): Promise<void> {
return new Promise((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release(): void {
if (this.queue.length > 0) {
const next = this.queue.shift();
if (next) next();
} else {
this.locked = false;
}
}
}
const verificationMutex = new VerificationMutex();
// Known error patterns that indicate auth failure // Known error patterns that indicate auth failure
const AUTH_ERROR_PATTERNS = [ const AUTH_ERROR_PATTERNS = [
'OAuth token revoked', 'OAuth token revoked',
@@ -68,14 +103,79 @@ function containsAuthError(text: string): boolean {
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase())); return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase()));
} }
/** Valid authentication method values */
const VALID_AUTH_METHODS = ['cli', 'api_key'] as const;
type AuthMethod = (typeof VALID_AUTH_METHODS)[number];
/**
* Validates and extracts the authMethod from the request body.
*
* @param body - The request body to validate
* @returns The validated authMethod or undefined if not provided
* @throws Error if authMethod is provided but invalid
*/
function validateAuthMethod(body: unknown): AuthMethod | undefined {
if (!body || typeof body !== 'object') {
return undefined;
}
const obj = body as Record<string, unknown>;
if (!('authMethod' in obj) || obj.authMethod === undefined || obj.authMethod === null) {
return undefined;
}
const authMethod = obj.authMethod;
if (typeof authMethod !== 'string') {
throw new Error(`Invalid authMethod type: expected string, got ${typeof authMethod}`);
}
if (!VALID_AUTH_METHODS.includes(authMethod as AuthMethod)) {
throw new Error(
`Invalid authMethod value: '${authMethod}'. Valid values: ${VALID_AUTH_METHODS.join(', ')}`
);
}
return authMethod as AuthMethod;
}
export function createVerifyClaudeAuthHandler() { export function createVerifyClaudeAuthHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
// Get the auth method from the request body // Validate and extract the auth method from the request body
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' }; let authMethod: AuthMethod | undefined;
try {
authMethod = validateAuthMethod(req.body);
} catch (validationError) {
res.status(400).json({
success: false,
authenticated: false,
error: validationError instanceof Error ? validationError.message : 'Invalid request',
});
return;
}
logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`); logger.info(`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}`);
// Early validation before acquiring mutex - check if API key is needed but missing
if (authMethod === 'api_key') {
const storedApiKey = getApiKey('anthropic');
if (!storedApiKey && !process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
}
// Acquire mutex to prevent race conditions when modifying process.env
// The SDK reads ANTHROPIC_API_KEY from environment, so concurrent requests
// could interfere with each other without this lock
await verificationMutex.acquire();
// Create an AbortController with a 30-second timeout // Create an AbortController with a 30-second timeout
const abortController = new AbortController(); const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 30000); const timeoutId = setTimeout(() => abortController.abort(), 30000);
@@ -84,7 +184,7 @@ export function createVerifyClaudeAuthHandler() {
let errorMessage = ''; let errorMessage = '';
let receivedAnyContent = false; let receivedAnyContent = false;
// Save original env values // Save original env values (inside mutex to ensure consistency)
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
try { try {
@@ -99,17 +199,8 @@ export function createVerifyClaudeAuthHandler() {
if (storedApiKey) { if (storedApiKey) {
process.env.ANTHROPIC_API_KEY = storedApiKey; process.env.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification'); logger.info('[Setup] Using stored API key for verification');
} else {
// Check env var
if (!process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
} }
// Note: if no stored key, we use the existing env var (already validated above)
} }
// Run a minimal query to verify authentication // Run a minimal query to verify authentication
@@ -129,7 +220,8 @@ export function createVerifyClaudeAuthHandler() {
for await (const msg of stream) { for await (const msg of stream) {
const msgStr = JSON.stringify(msg); const msgStr = JSON.stringify(msg);
allMessages.push(msgStr); allMessages.push(msgStr);
logger.info('[Setup] Stream message:', msgStr.substring(0, 500)); // Debug log only message type to avoid leaking sensitive data
logger.debug('[Setup] Stream message type:', msg.type);
// Check for billing errors FIRST - these should fail verification // Check for billing errors FIRST - these should fail verification
if (isBillingError(msgStr)) { if (isBillingError(msgStr)) {
@@ -221,7 +313,8 @@ export function createVerifyClaudeAuthHandler() {
} else { } else {
// No content received - might be an issue // No content received - might be an issue
logger.warn('[Setup] No content received from stream'); logger.warn('[Setup] No content received from stream');
logger.warn('[Setup] All messages:', allMessages.join('\n')); // Log only message count to avoid leaking sensitive data
logger.warn('[Setup] Total messages received:', allMessages.length);
errorMessage = 'No response received from Claude. Please check your authentication.'; errorMessage = 'No response received from Claude. Please check your authentication.';
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -277,6 +370,8 @@ export function createVerifyClaudeAuthHandler() {
// If we cleared it and there was no original, keep it cleared // If we cleared it and there was no original, keep it cleared
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
} }
// Release the mutex so other verification requests can proceed
verificationMutex.release();
} }
logger.info('[Setup] Verification result:', { logger.info('[Setup] Verification result:', {

View File

@@ -6,94 +6,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../lib/events.js'; import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { createSuggestionsOptions } from '../../lib/sdk-options.js'; import { createSuggestionsOptions } from '../../lib/sdk-options.js';
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';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
/**
* Extract implemented features from app_spec.txt XML content
*
* Note: This uses regex-based parsing which is sufficient for our controlled
* XML structure. If more complex XML parsing is needed in the future, consider
* using a library like 'fast-xml-parser' or 'xml2js'.
*/
function extractImplementedFeatures(specContent: string): string[] {
const features: string[] = [];
// Match <implemented_features>...</implemented_features> section
const implementedMatch = specContent.match(
/<implemented_features>([\s\S]*?)<\/implemented_features>/
);
if (implementedMatch) {
const implementedSection = implementedMatch[1];
// Extract feature names from <name>...</name> tags using matchAll
const nameRegex = /<name>(.*?)<\/name>/g;
const matches = implementedSection.matchAll(nameRegex);
for (const match of matches) {
features.push(match[1].trim());
}
}
return features;
}
/**
* Load existing context (app spec and backlog features) to avoid duplicates
*/
async function loadExistingContext(projectPath: string): Promise<string> {
let context = '';
// 1. Read app_spec.txt for implemented features
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
if (specContent && specContent.trim().length > 0) {
const implementedFeatures = extractImplementedFeatures(specContent);
if (implementedFeatures.length > 0) {
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
context += 'These features are already implemented in the codebase:\n';
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
}
}
} catch (error) {
// app_spec.txt doesn't exist or can't be read - that's okay
logger.debug('No app_spec.txt found or error reading it:', error);
}
// 2. Load existing features from backlog
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
if (features.length > 0) {
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
context += 'These features are already planned or in progress:\n';
context +=
features
.map((feature) => {
const status = feature.status || 'pending';
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
return `- ${title} (${status})`;
})
.join('\n') + '\n';
}
} catch (error) {
// Features directory doesn't exist or can't be read - that's okay
logger.debug('No features found or error loading them:', error);
}
return context;
}
/** /**
* JSON Schema for suggestions output * JSON Schema for suggestions output
*/ */
@@ -127,8 +42,7 @@ export async function generateSuggestions(
projectPath: string, projectPath: string,
suggestionType: string, suggestionType: string,
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController
settingsService?: SettingsService
): Promise<void> { ): Promise<void> {
const typePrompts: Record<string, string> = { const typePrompts: Record<string, string> = {
features: 'Analyze this project and suggest new features that would add value.', features: 'Analyze this project and suggest new features that would add value.',
@@ -137,13 +51,8 @@ export async function generateSuggestions(
performance: 'Analyze this project for performance issues and suggest optimizations.', performance: 'Analyze this project for performance issues and suggest optimizations.',
}; };
// Load existing context to avoid duplicates
const existingContext = await loadExistingContext(projectPath);
const prompt = `${typePrompts[suggestionType] || typePrompts.features} const prompt = `${typePrompts[suggestionType] || typePrompts.features}
${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. Look at the codebase and provide 3-5 concrete suggestions.
For each suggestion, provide: For each suggestion, provide:
@@ -157,17 +66,9 @@ The response will be automatically formatted as structured JSON.`;
// Don't send initial message - let the agent output speak for itself // Don't send initial message - let the agent output speak for itself
// The first agent message will be captured as an info entry // The first agent message will be captured as an info entry
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
const options = createSuggestionsOptions({ const options = createSuggestionsOptions({
cwd: projectPath, cwd: projectPath,
abortController, abortController,
autoLoadClaudeMd,
outputFormat: { outputFormat: {
type: 'json_schema', type: 'json_schema',
schema: suggestionsSchema, schema: suggestionsSchema,

View File

@@ -8,19 +8,11 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGenerateHandler } from './routes/generate.js'; import { createGenerateHandler } from './routes/generate.js';
import { createStopHandler } from './routes/stop.js'; import { createStopHandler } from './routes/stop.js';
import { createStatusHandler } from './routes/status.js'; import { createStatusHandler } from './routes/status.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createSuggestionsRoutes( export function createSuggestionsRoutes(events: EventEmitter): Router {
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post( router.post('/generate', validatePathParams('projectPath'), createGenerateHandler(events));
'/generate',
validatePathParams('projectPath'),
createGenerateHandler(events, settingsService)
);
router.post('/stop', createStopHandler()); router.post('/stop', createStopHandler());
router.get('/status', createStatusHandler()); router.get('/status', createStatusHandler());

View File

@@ -7,11 +7,10 @@ import type { EventEmitter } from '../../../lib/events.js';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js'; import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
import { generateSuggestions } from '../generate-suggestions.js'; import { generateSuggestions } from '../generate-suggestions.js';
import type { SettingsService } from '../../../services/settings-service.js';
const logger = createLogger('Suggestions'); const logger = createLogger('Suggestions');
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, suggestionType = 'features' } = req.body as { const { projectPath, suggestionType = 'features' } = req.body as {
@@ -38,7 +37,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
setRunningState(true, abortController); setRunningState(true, abortController);
// Start generation in background // Start generation in background
generateSuggestions(projectPath, suggestionType, events, abortController, settingsService) generateSuggestions(projectPath, suggestionType, events, abortController)
.catch((error) => { .catch((error) => {
logError(error, 'Generate suggestions failed (background)'); logError(error, 'Generate suggestions failed (background)');
events.emit('suggestions:event', { events.emit('suggestions:event', {

View File

@@ -65,6 +65,18 @@ export function cleanupExpiredTokens(): void {
// Clean up expired tokens every 5 minutes // Clean up expired tokens every 5 minutes
setInterval(cleanupExpiredTokens, 5 * 60 * 1000); setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
/**
* Extract Bearer token from Authorization header
* Returns undefined if header is missing or malformed
*/
export function extractBearerToken(req: Request): string | undefined {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return undefined;
}
return authHeader.slice(7); // Remove 'Bearer ' prefix
}
/** /**
* Validate a terminal session token * Validate a terminal session token
*/ */
@@ -116,8 +128,9 @@ export function terminalAuthMiddleware(req: Request, res: Response, next: NextFu
return; return;
} }
// Check for session token // Extract token from Authorization header only (Bearer token format)
const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string); // Query string tokens are not supported due to security risks (URL logging, referrer leakage)
const token = extractBearerToken(req);
if (!validateTerminalToken(token)) { if (!validateTerminalToken(token)) {
res.status(401).json({ res.status(401).json({

View File

@@ -1,7 +1,9 @@
/** /**
* POST /auth endpoint - Authenticate with password to get a session token * POST /auth endpoint - Authenticate with password to get a session token
* Includes rate limiting to prevent brute-force attacks.
*/ */
import * as crypto from 'crypto';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { import {
getTerminalEnabledConfigValue, getTerminalEnabledConfigValue,
@@ -11,6 +13,25 @@ import {
getTokenExpiryMs, getTokenExpiryMs,
getErrorMessage, getErrorMessage,
} from '../common.js'; } from '../common.js';
import { terminalAuthRateLimiter } from '../../../lib/rate-limiter.js';
/**
* Performs a constant-time string comparison to prevent timing attacks.
* Uses crypto.timingSafeEqual with proper buffer handling.
*/
function secureCompare(a: string, b: string): boolean {
const bufferA = Buffer.from(a, 'utf8');
const bufferB = Buffer.from(b, 'utf8');
// If lengths differ, we still need to do a constant-time comparison
// to avoid leaking length information. We compare against bufferA twice.
if (bufferA.length !== bufferB.length) {
crypto.timingSafeEqual(bufferA, bufferA);
return false;
}
return crypto.timingSafeEqual(bufferA, bufferB);
}
export function createAuthHandler() { export function createAuthHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
@@ -36,9 +57,28 @@ export function createAuthHandler() {
return; return;
} }
const clientIp = terminalAuthRateLimiter.getClientIp(req);
// Check if client is rate limited
if (terminalAuthRateLimiter.isBlocked(clientIp)) {
const retryAfterMs = terminalAuthRateLimiter.getBlockTimeRemaining(clientIp);
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
error: 'Too many failed authentication attempts. Please try again later.',
retryAfter: retryAfterSeconds,
});
return;
}
const { password } = req.body; const { password } = req.body;
if (!password || password !== terminalPassword) { if (!password || !secureCompare(password, terminalPassword)) {
// Record failed attempt
terminalAuthRateLimiter.recordFailure(clientIp);
res.status(401).json({ res.status(401).json({
success: false, success: false,
error: 'Invalid password', error: 'Invalid password',
@@ -46,6 +86,9 @@ export function createAuthHandler() {
return; return;
} }
// Successful authentication - reset rate limiter for this IP
terminalAuthRateLimiter.reset(clientIp);
// Generate session token // Generate session token
const token = generateToken(); const token = generateToken();
const now = new Date(); const now = new Date();

View File

@@ -1,18 +1,36 @@
/** /**
* POST /logout endpoint - Invalidate a session token * POST /logout endpoint - Invalidate a session token
*
* Security: Only allows invalidating the token used for authentication.
* This ensures users can only log out their own sessions.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { deleteToken } from '../common.js'; import { deleteToken, extractBearerToken, validateTerminalToken } from '../common.js';
export function createLogoutHandler() { export function createLogoutHandler() {
return (req: Request, res: Response): void => { return (req: Request, res: Response): void => {
const token = (req.headers['x-terminal-token'] as string) || req.body.token; const token = extractBearerToken(req);
if (token) { if (!token) {
deleteToken(token); res.status(401).json({
success: false,
error: 'Authorization header with Bearer token is required',
});
return;
} }
if (!validateTerminalToken(token)) {
res.status(401).json({
success: false,
error: 'Invalid or expired token',
});
return;
}
// Token is valid and belongs to the requester - safe to invalidate
deleteToken(token);
res.json({ res.json({
success: true, success: true,
}); });

View File

@@ -56,56 +56,32 @@ export function createCreatePRHandler() {
} }
// Check for uncommitted changes // Check for uncommitted changes
console.log(`[CreatePR] Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', { const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
const hasChanges = status.trim().length > 0; const hasChanges = status.trim().length > 0;
console.log(`[CreatePR] Has uncommitted changes: ${hasChanges}`);
if (hasChanges) {
console.log(`[CreatePR] Changed files:\n${status}`);
}
// If there are changes, commit them // If there are changes, commit them
let commitHash: string | null = null; let commitHash: string | null = null;
let commitError: string | null = null;
if (hasChanges) { if (hasChanges) {
const message = commitMessage || `Changes from ${branchName}`; const message = commitMessage || `Changes from ${branchName}`;
console.log(`[CreatePR] Committing changes with message: ${message}`);
try { // Stage all changes
// Stage all changes await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
console.log(`[CreatePR] Running: git add -A`);
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit // Create commit
console.log(`[CreatePR] Running: git commit`); await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: worktreePath,
cwd: worktreePath, env: execEnv,
env: execEnv, });
});
// Get commit hash // Get commit hash
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
cwd: worktreePath, cwd: worktreePath,
env: execEnv, env: execEnv,
}); });
commitHash = hashOutput.trim().substring(0, 8); commitHash = hashOutput.trim().substring(0, 8);
console.log(`[CreatePR] Commit successful: ${commitHash}`);
} catch (commitErr: unknown) {
const err = commitErr as { stderr?: string; message?: string };
commitError = err.stderr || err.message || 'Commit failed';
console.error(`[CreatePR] Commit failed: ${commitError}`);
// Return error immediately - don't proceed with push/PR if commit fails
res.status(500).json({
success: false,
error: `Failed to commit changes: ${commitError}`,
commitError,
});
return;
}
} }
// Push the branch to remote // Push the branch to remote
@@ -384,9 +360,8 @@ export function createCreatePRHandler() {
success: true, success: true,
result: { result: {
branch: branchName, branch: branchName,
committed: hasChanges && !commitError, committed: hasChanges,
commitHash, commitHash,
commitError: commitError || undefined,
pushed: true, pushed: true,
prUrl, prUrl,
prNumber, prNumber,

View File

@@ -16,14 +16,6 @@ import {
import { ProviderFactory } from '../providers/provider-factory.js'; import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform'; import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
} from '../lib/settings-helpers.js';
interface Message { interface Message {
id: string; id: string;
@@ -38,14 +30,6 @@ interface Message {
isError?: boolean; isError?: boolean;
} }
interface QueuedPrompt {
id: string;
message: string;
imagePaths?: string[];
model?: string;
addedAt: string;
}
interface Session { interface Session {
messages: Message[]; messages: Message[];
isRunning: boolean; isRunning: boolean;
@@ -53,7 +37,6 @@ interface Session {
workingDirectory: string; workingDirectory: string;
model?: string; model?: string;
sdkSessionId?: string; // Claude SDK session ID for conversation continuity sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
} }
interface SessionMetadata { interface SessionMetadata {
@@ -74,13 +57,11 @@ export class AgentService {
private stateDir: string; private stateDir: string;
private metadataFile: string; private metadataFile: string;
private events: EventEmitter; private events: EventEmitter;
private settingsService: SettingsService | null = null;
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) { constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, 'agent-sessions'); this.stateDir = path.join(dataDir, 'agent-sessions');
this.metadataFile = path.join(dataDir, 'sessions-metadata.json'); this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
this.events = events; this.events = events;
this.settingsService = settingsService ?? null;
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
@@ -109,16 +90,12 @@ export class AgentService {
// Validate that the working directory is allowed using centralized validation // Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory); validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
this.sessions.set(sessionId, { this.sessions.set(sessionId, {
messages, messages,
isRunning: false, isRunning: false,
abortController: null, abortController: null,
workingDirectory: resolvedWorkingDirectory, workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
promptQueue,
}); });
} }
@@ -148,12 +125,10 @@ export class AgentService {
}) { }) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
console.error('[AgentService] ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`); throw new Error(`Session ${sessionId} not found`);
} }
if (session.isRunning) { if (session.isRunning) {
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
throw new Error('Agent is already processing a message'); throw new Error('Agent is already processing a message');
} }
@@ -199,11 +174,6 @@ export class AgentService {
session.isRunning = true; session.isRunning = true;
session.abortController = new AbortController(); session.abortController = new AbortController();
// Emit started event so UI can show thinking indicator
this.emitAgentEvent(sessionId, {
type: 'started',
});
// Emit user message event // Emit user message event
this.emitAgentEvent(sessionId, { this.emitAgentEvent(sessionId, {
type: 'message', type: 'message',
@@ -216,35 +186,12 @@ export class AgentService {
// Determine the effective working directory for context loading // Determine the effective working directory for context loading
const effectiveWorkDir = workingDirectory || session.workingDirectory; const effectiveWorkDir = workingDirectory || session.workingDirectory;
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
effectiveWorkDir,
this.settingsService,
'[AgentService]'
);
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(
this.settingsService,
'[AgentService]'
);
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath: effectiveWorkDir, projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build combined system prompt with base prompt and context files // Build combined system prompt with base prompt and context files
const baseSystemPrompt = this.getSystemPrompt(); const baseSystemPrompt = this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt const combinedSystemPrompt = contextFilesPrompt
@@ -258,11 +205,6 @@ export class AgentService {
sessionModel: session.model, sessionModel: session.model,
systemPrompt: combinedSystemPrompt, systemPrompt: combinedSystemPrompt,
abortController: session.abortController!, abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
}); });
// Extract model, maxTurns, and allowedTools from SDK options // Extract model, maxTurns, and allowedTools from SDK options
@@ -273,22 +215,21 @@ export class AgentService {
// Get provider for this model // Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel); const provider = ProviderFactory.getProviderForModel(effectiveModel);
console.log(
`[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"`
);
// Build options for provider // Build options for provider
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt: '', // Will be set below based on images prompt: '', // Will be set below based on images
model: effectiveModel, model: effectiveModel,
cwd: effectiveWorkDir, cwd: effectiveWorkDir,
systemPrompt: sdkOptions.systemPrompt, systemPrompt: combinedSystemPrompt,
maxTurns: maxTurns, maxTurns: maxTurns,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController: session.abortController!, abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
}; };
// Build prompt content with images // Build prompt content with images
@@ -313,6 +254,7 @@ export class AgentService {
// Capture SDK session ID from any message and persist it // Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) { if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id; session.sdkSessionId = msg.session_id;
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
// Persist the SDK session ID to ensure conversation continuity across server restarts // Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
} }
@@ -377,9 +319,6 @@ export class AgentService {
session.isRunning = false; session.isRunning = false;
session.abortController = null; session.abortController = null;
// Process next item in queue after completion
setImmediate(() => this.processNextInQueue(sessionId));
return { return {
success: true, success: true,
message: currentAssistantMessage, message: currentAssistantMessage,
@@ -618,165 +557,6 @@ export class AgentService {
return true; return true;
} }
// Queue management methods
/**
* Add a prompt to the queue for later execution
*/
async addToQueue(
sessionId: string,
prompt: { message: string; imagePaths?: string[]; model?: string }
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
const queuedPrompt: QueuedPrompt = {
id: this.generateId(),
message: prompt.message,
imagePaths: prompt.imagePaths,
model: prompt.model,
addedAt: new Date().toISOString(),
};
session.promptQueue.push(queuedPrompt);
await this.saveQueueState(sessionId, session.promptQueue);
// Emit queue update event
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
return { success: true, queuedPrompt };
}
/**
* Get the current queue for a session
*/
getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
return { success: true, queue: session.promptQueue };
}
/**
* Remove a specific prompt from the queue
*/
async removeFromQueue(
sessionId: string,
promptId: string
): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
const index = session.promptQueue.findIndex((p) => p.id === promptId);
if (index === -1) {
return { success: false, error: 'Prompt not found in queue' };
}
session.promptQueue.splice(index, 1);
await this.saveQueueState(sessionId, session.promptQueue);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
return { success: true };
}
/**
* Clear all prompts from the queue
*/
async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
session.promptQueue = [];
await this.saveQueueState(sessionId, []);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: [],
});
return { success: true };
}
/**
* Save queue state to disk for persistence
*/
private async saveQueueState(sessionId: string, queue: QueuedPrompt[]): Promise<void> {
const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`);
try {
await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8');
} catch (error) {
console.error('[AgentService] Failed to save queue state:', error);
}
}
/**
* Load queue state from disk
*/
private async loadQueueState(sessionId: string): Promise<QueuedPrompt[]> {
const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`);
try {
const data = (await secureFs.readFile(queueFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return [];
}
}
/**
* Process the next item in the queue (called after task completion)
*/
private async processNextInQueue(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || session.promptQueue.length === 0) {
return;
}
// Don't process if already running
if (session.isRunning) {
return;
}
const nextPrompt = session.promptQueue.shift();
if (!nextPrompt) return;
await this.saveQueueState(sessionId, session.promptQueue);
this.emitAgentEvent(sessionId, {
type: 'queue_updated',
queue: session.promptQueue,
});
try {
await this.sendMessage({
sessionId,
message: nextPrompt.message,
imagePaths: nextPrompt.imagePaths,
model: nextPrompt.model,
});
} catch (error) {
console.error('[AgentService] Failed to process queued prompt:', error);
this.emitAgentEvent(sessionId, {
type: 'queue_error',
error: (error as Error).message,
promptId: nextPrompt.id,
});
}
}
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void { private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data }); this.events.emit('agent:stream', { sessionId, ...data });
} }

View File

@@ -10,7 +10,7 @@
*/ */
import { ProviderFactory } from '../providers/provider-factory.js'; import { ProviderFactory } from '../providers/provider-factory.js';
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types'; import type { ExecuteOptions, Feature } from '@automaker/types';
import { import {
buildPromptWithImages, buildPromptWithImages,
isAbortError, isAbortError,
@@ -25,21 +25,8 @@ import { promisify } from 'util';
import path from 'path'; import path from 'path';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import { import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
createAutoModeOptions,
createCustomOptions,
validateWorkingDirectory,
} from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js'; import { FeatureLoader } from './feature-loader.js';
import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
} from '../lib/settings-helpers.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -354,11 +341,9 @@ export class AutoModeService {
private autoLoopAbortController: AbortController | null = null; private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null; private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>(); private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
constructor(events: EventEmitter, settingsService?: SettingsService) { constructor(events: EventEmitter) {
this.events = events; this.events = events;
this.settingsService = settingsService ?? null;
} }
/** /**
@@ -566,25 +551,14 @@ export class AutoModeService {
// Update feature status to in_progress // Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Build the prompt - use continuation prompt if provided (for recovery after plan approval) // Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string; let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath, projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
if (options?.continuationPrompt) { if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval // Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase // The plan was already approved, so skip the planning phase
@@ -630,27 +604,9 @@ export class AutoModeService {
planningMode: feature.planningMode, planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval, requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
} }
); );
// Check for pipeline steps and execute them
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially
await this.executePipelineSteps(
projectPath,
featureId,
feature,
sortedSteps,
workDir,
abortController,
autoLoadClaudeMd
);
}
// Determine final status based on testing mode: // Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review // - skipTests=true (manual verification): go to 'waiting_approval' for manual review
@@ -694,143 +650,6 @@ export class AutoModeService {
} }
} }
/**
* Execute pipeline steps sequentially after initial feature implementation
*/
private async executePipelineSteps(
projectPath: string,
featureId: string,
feature: Feature,
steps: PipelineStep[],
workDir: string,
abortController: AbortController,
autoLoadClaudeMd: boolean
): Promise<void> {
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Load context files once
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Load previous agent output for context continuity
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const pipelineStatus = `pipeline_${step.id}`;
// Update feature status to current pipeline step
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
this.emitAutoModeEvent('pipeline_step_started', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
// Build prompt for this pipeline step
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
// Run the agent for this pipeline step
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
undefined, // no images for pipeline steps
model,
{
projectPath,
planningMode: 'skip', // Pipeline steps don't need planning
requirePlanApproval: false,
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
// Load updated context for next step
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No context update
}
this.emitAutoModeEvent('pipeline_step_complete', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
console.log(
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
);
}
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
}
/**
* Build the prompt for a pipeline step
*/
private buildPipelineStepPrompt(
step: PipelineStep,
feature: Feature,
previousContext: string
): string {
let prompt = `## Pipeline Step: ${step.name}
This is an automated pipeline step following the initial feature implementation.
### Feature Context
${this.buildFeaturePrompt(feature)}
`;
if (previousContext) {
prompt += `### Previous Work
The following is the output from the previous work on this feature:
${previousContext}
`;
}
prompt += `### Pipeline Step Instructions
${step.instructions}
### Task
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
return prompt;
}
/** /**
* Stop a specific feature * Stop a specific feature
*/ */
@@ -927,23 +746,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
// No previous context // No previous context
} }
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({ const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath, projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'], fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
}); });
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build complete prompt with feature info, previous context, and follow-up instructions // Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation let fullPrompt = `## Follow-up on Feature Implementation
@@ -1071,7 +879,6 @@ Address the follow-up instructions above. Review the previous work and make the
planningMode: 'skip', // Follow-ups don't require approval planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined, previousContent: previousContext || undefined,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
} }
); );
@@ -1258,6 +1065,11 @@ Address the follow-up instructions above. Review the previous work and make the
* Analyze project to gather context * Analyze project to gather context
*/ */
async analyzeProject(projectPath: string): Promise<void> { async analyzeProject(projectPath: string): Promise<void> {
// Validate project path before proceeding
// This is called here because analyzeProject builds ExecuteOptions directly
// without using a factory function from sdk-options.ts
validateWorkingDirectory(projectPath);
const abortController = new AbortController(); const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`; const analysisFeatureId = `analysis-${Date.now()}`;
@@ -1285,32 +1097,13 @@ Format your response as a structured markdown document.`;
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(analysisModel); const provider = ProviderFactory.getProviderForModel(analysisModel);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Use createCustomOptions for centralized SDK configuration with CLAUDE.md support
const sdkOptions = createCustomOptions({
cwd: projectPath,
model: analysisModel,
maxTurns: 5,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
autoLoadClaudeMd,
});
const options: ExecuteOptions = { const options: ExecuteOptions = {
prompt, prompt,
model: sdkOptions.model ?? analysisModel, model: analysisModel,
cwd: sdkOptions.cwd ?? projectPath, maxTurns: 5,
maxTurns: sdkOptions.maxTurns, cwd: projectPath,
allowedTools: sdkOptions.allowedTools as string[], allowedTools: ['Read', 'Glob', 'Grep'],
abortController, abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
}; };
const stream = provider.executeQuery(options); const stream = provider.executeQuery(options);
@@ -1915,7 +1708,6 @@ This helps parse your summary correctly in the output logs.`;
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean;
} }
): Promise<void> { ): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath; const finalProjectPath = options?.projectPath || projectPath;
@@ -1988,32 +1780,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
return; return;
} }
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
// Use provided value if available, otherwise load from settings
const autoLoadClaudeMd =
options?.autoLoadClaudeMd !== undefined
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
// Build SDK options using centralized configuration for feature implementation // Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({ const sdkOptions = createAutoModeOptions({
cwd: workDir, cwd: workDir,
model: model, model: model,
abortController, abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
}); });
// Extract model, maxTurns, and allowedTools from SDK options // Extract model, maxTurns, and allowedTools from SDK options
@@ -2052,12 +1823,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
systemPrompt: sdkOptions.systemPrompt, systemPrompt: options?.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
}; };
// Execute via provider // Execute via provider
@@ -2285,9 +2051,6 @@ After generating the revised spec, output:
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
}); });
let revisionText = ''; let revisionText = '';
@@ -2425,9 +2188,6 @@ After generating the revised spec, output:
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
}); });
let taskOutput = ''; let taskOutput = '';
@@ -2517,9 +2277,6 @@ Implement all the changes described in the plan above.`;
cwd: workDir, cwd: workDir,
allowedTools: allowedTools, allowedTools: allowedTools,
abortController, abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
}); });
for await (const msg of continuationStream) { for await (const msg of continuationStream) {

View File

@@ -1,208 +0,0 @@
/**
* MCP Test Service
*
* Provides functionality to test MCP server connections and list available tools.
* Supports stdio, SSE, and HTTP transport types.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
import type { SettingsService } from './settings-service.js';
const DEFAULT_TIMEOUT = 10000; // 10 seconds
export interface MCPTestResult {
success: boolean;
tools?: MCPToolInfo[];
error?: string;
connectionTime?: number;
serverInfo?: {
name?: string;
version?: string;
};
}
/**
* MCP Test Service for testing server connections and listing tools
*/
export class MCPTestService {
private settingsService: SettingsService;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
/**
* Test connection to an MCP server and list its tools
*/
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
const startTime = Date.now();
let client: Client | null = null;
try {
client = new Client({
name: 'automaker-mcp-test',
version: '1.0.0',
});
// Create transport based on server type
const transport = await this.createTransport(serverConfig);
// Connect with timeout
await Promise.race([
client.connect(transport),
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
]);
// List tools with timeout
const toolsResult = await Promise.race([
client.listTools(),
this.timeout<{
tools: Array<{
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}>;
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
]);
const connectionTime = Date.now() - startTime;
// Convert tools to MCPToolInfo format
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
enabled: true,
})
);
return {
success: true,
tools,
connectionTime,
serverInfo: {
name: serverConfig.name,
version: undefined, // Could be extracted from server info if available
},
};
} catch (error) {
const connectionTime = Date.now() - startTime;
return {
success: false,
error: this.getErrorMessage(error),
connectionTime,
};
} finally {
// Clean up client connection
if (client) {
try {
await client.close();
} catch {
// Ignore cleanup errors
}
}
}
}
/**
* Test server by ID (looks up config from settings)
*/
async testServerById(serverId: string): Promise<MCPTestResult> {
try {
const globalSettings = await this.settingsService.getGlobalSettings();
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
if (!serverConfig) {
return {
success: false,
error: `Server with ID "${serverId}" not found`,
};
}
return this.testServer(serverConfig);
} catch (error) {
return {
success: false,
error: this.getErrorMessage(error),
};
}
}
/**
* Create appropriate transport based on server type
*/
private async createTransport(
config: MCPServerConfig
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
if (config.type === 'sse') {
if (!config.url) {
throw new Error('URL is required for SSE transport');
}
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
const headers = config.headers;
return new SSEClientTransport(new URL(config.url), {
requestInit: headers ? { headers } : undefined,
eventSourceInit: headers
? {
fetch: (url: string | URL | Request, init?: RequestInit) => {
const fetchHeaders = new Headers(init?.headers || {});
for (const [key, value] of Object.entries(headers)) {
fetchHeaders.set(key, value);
}
return fetch(url, { ...init, headers: fetchHeaders });
},
}
: undefined,
});
}
if (config.type === 'http') {
if (!config.url) {
throw new Error('URL is required for HTTP transport');
}
return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers
? {
headers: config.headers,
}
: undefined,
});
}
// Default to stdio
if (!config.command) {
throw new Error('Command is required for stdio transport');
}
return new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
});
}
/**
* Create a timeout promise
*/
private timeout<T>(ms: number, message: string): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
/**
* Extract error message from unknown error
*/
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -1,320 +0,0 @@
/**
* Pipeline Service - Handles reading/writing pipeline configuration
*
* Provides persistent storage for:
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
*/
import path from 'path';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { ensureAutomakerDir } from '@automaker/platform';
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
const logger = createLogger('PipelineService');
// Default empty pipeline config
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
version: 1,
steps: [],
};
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
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<T>(filePath: string, defaultValue: T): Promise<T> {
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;
}
}
/**
* Generate a unique ID for pipeline steps
*/
function generateStepId(): string {
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Get the pipeline config file path for a project
*/
function getPipelineConfigPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'pipeline.json');
}
/**
* PipelineService - Manages pipeline configuration for workflow automation
*
* Handles reading and writing pipeline config to JSON files with atomic operations.
* Pipeline steps define custom columns that appear between "in_progress" and
* "waiting_approval/verified" columns in the kanban board.
*/
export class PipelineService {
/**
* Get pipeline configuration for a project
*
* @param projectPath - Absolute path to the project
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
*/
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
const configPath = getPipelineConfigPath(projectPath);
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
// Ensure version is set
return {
...DEFAULT_PIPELINE_CONFIG,
...config,
};
}
/**
* Save entire pipeline configuration
*
* @param projectPath - Absolute path to the project
* @param config - Complete PipelineConfig to save
*/
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
await ensureAutomakerDir(projectPath);
const configPath = getPipelineConfigPath(projectPath);
await atomicWriteJson(configPath, config);
logger.info(`Pipeline config saved for project: ${projectPath}`);
}
/**
* Add a new pipeline step
*
* @param projectPath - Absolute path to the project
* @param step - Step data (without id, createdAt, updatedAt)
* @returns Promise resolving to the created PipelineStep
*/
async addStep(
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const now = new Date().toISOString();
const newStep: PipelineStep = {
...step,
id: generateStepId(),
createdAt: now,
updatedAt: now,
};
config.steps.push(newStep);
// Normalize order values
config.steps.sort((a, b) => a.order - b.order);
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
return newStep;
}
/**
* Update an existing pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to update
* @param updates - Partial step data to merge
*/
async updateStep(
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps[stepIndex] = {
...config.steps[stepIndex],
...updates,
updatedAt: new Date().toISOString(),
};
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step updated: ${stepId}`);
return config.steps[stepIndex];
}
/**
* Delete a pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to delete
*/
async deleteStep(projectPath: string, stepId: string): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps.splice(stepIndex, 1);
// Normalize order values after deletion
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step deleted: ${stepId}`);
}
/**
* Reorder pipeline steps
*
* @param projectPath - Absolute path to the project
* @param stepIds - Array of step IDs in the desired order
*/
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
// Validate all step IDs exist
const existingIds = new Set(config.steps.map((s) => s.id));
for (const id of stepIds) {
if (!existingIds.has(id)) {
throw new Error(`Pipeline step not found: ${id}`);
}
}
// Create a map for quick lookup
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
// Reorder steps based on stepIds array
config.steps = stepIds.map((id, index) => {
const step = stepMap.get(id)!;
return { ...step, order: index, updatedAt: new Date().toISOString() };
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline steps reordered`);
}
/**
* Get the next status in the pipeline flow
*
* Determines what status a feature should transition to based on current status.
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
*
* @param currentStatus - Current feature status
* @param config - Pipeline configuration (or null if no pipeline)
* @param skipTests - Whether to skip tests (affects final status)
* @returns The next status in the pipeline flow
*/
getNextStatus(
currentStatus: FeatureStatusWithPipeline,
config: PipelineConfig | null,
skipTests: boolean
): FeatureStatusWithPipeline {
const steps = config?.steps || [];
// Sort steps by order
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
// If no pipeline steps, use original logic
if (sortedSteps.length === 0) {
if (currentStatus === 'in_progress') {
return skipTests ? 'waiting_approval' : 'verified';
}
return currentStatus;
}
// Coming from in_progress -> go to first pipeline step
if (currentStatus === 'in_progress') {
return `pipeline_${sortedSteps[0].id}`;
}
// Coming from a pipeline step -> go to next step or final status
if (currentStatus.startsWith('pipeline_')) {
const currentStepId = currentStatus.replace('pipeline_', '');
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
if (currentIndex === -1) {
// Step not found, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
if (currentIndex < sortedSteps.length - 1) {
// Go to next step
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
}
// Last step completed, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
// For other statuses, don't change
return currentStatus;
}
/**
* Get a specific pipeline step by ID
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to retrieve
* @returns The pipeline step or null if not found
*/
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
const config = await this.getPipelineConfig(projectPath);
return config.steps.find((s) => s.id === stepId) || null;
}
/**
* Check if a status is a pipeline status
*/
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
if (!this.isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}
}
// Export singleton instance
export const pipelineService = new PipelineService();

View File

@@ -1,6 +1,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js'; import { createMockExpressContext } from '../../utils/mocks.js';
/**
* Creates a mock Express context with socket properties for rate limiter support
*/
function createMockExpressContextWithSocket() {
const ctx = createMockExpressContext();
ctx.req.socket = { remoteAddress: '127.0.0.1' } as any;
ctx.res.setHeader = vi.fn().mockReturnThis();
return ctx;
}
/** /**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time. * Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
* We need to reset modules and reimport for each test to get fresh state. * We need to reset modules and reimport for each test to get fresh state.
@@ -29,7 +39,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContextWithSocket();
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -45,7 +55,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'wrong-key'; req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -62,7 +72,7 @@ describe('auth.ts', () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key'; process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js'); const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext(); const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'test-secret-key'; req.headers['x-api-key'] = 'test-secret-key';
authMiddleware(req, res, next); authMiddleware(req, res, next);
@@ -113,4 +123,197 @@ describe('auth.ts', () => {
}); });
}); });
}); });
describe('security - AUTOMAKER_API_KEY not set', () => {
it('should allow requests without any authentication when API key is not configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('should allow requests even with invalid key header when API key is not configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.headers['x-api-key'] = 'some-random-key';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should report auth as disabled when no API key is configured', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled, getAuthStatus } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false);
expect(getAuthStatus()).toEqual({
enabled: false,
method: 'none',
});
});
});
describe('security - authentication correctness', () => {
it('should correctly authenticate with matching API key', async () => {
const testKey = 'correct-secret-key-12345';
process.env.AUTOMAKER_API_KEY = testKey;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = testKey;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject keys that differ by a single character', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'correct-secret-keY'; // Last char uppercase
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject keys with extra characters', async () => {
process.env.AUTOMAKER_API_KEY = 'secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'secret-key-extra';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject keys that are a prefix of the actual key', async () => {
process.env.AUTOMAKER_API_KEY = 'full-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = 'full-secret';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('should reject empty string API key header', async () => {
process.env.AUTOMAKER_API_KEY = 'secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = '';
authMiddleware(req, res, next);
// Empty string is falsy, so should get 401 (no key provided)
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('should handle keys with special characters correctly', async () => {
const specialKey = 'key-with-$pecial!@#chars_123';
process.env.AUTOMAKER_API_KEY = specialKey;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContextWithSocket();
req.headers['x-api-key'] = specialKey;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('security - rate limiting', () => {
it('should block requests after multiple failed attempts', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
// Reset the rate limiter for this test
apiKeyRateLimiter.reset('192.168.1.100');
// Simulate multiple failed attempts
for (let i = 0; i < 5; i++) {
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.100';
req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next);
}
// Next request should be rate limited
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.100';
req.headers['x-api-key'] = 'correct-key'; // Even with correct key
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(429);
expect(next).not.toHaveBeenCalled();
// Cleanup
apiKeyRateLimiter.reset('192.168.1.100');
});
it('should reset rate limit on successful authentication', async () => {
process.env.AUTOMAKER_API_KEY = 'correct-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
// Reset the rate limiter for this test
apiKeyRateLimiter.reset('192.168.1.101');
// Simulate a few failed attempts (not enough to trigger block)
for (let i = 0; i < 3; i++) {
const { req, res, next } = createMockExpressContextWithSocket();
req.socket.remoteAddress = '192.168.1.101';
req.headers['x-api-key'] = 'wrong-key';
authMiddleware(req, res, next);
}
// Successful authentication should reset the counter
const {
req: successReq,
res: successRes,
next: successNext,
} = createMockExpressContextWithSocket();
successReq.socket.remoteAddress = '192.168.1.101';
successReq.headers['x-api-key'] = 'correct-key';
authMiddleware(successReq, successRes, successNext);
expect(successNext).toHaveBeenCalled();
// After reset, we should have full attempts available again
expect(apiKeyRateLimiter.getAttemptsRemaining('192.168.1.101')).toBe(5);
// Cleanup
apiKeyRateLimiter.reset('192.168.1.101');
});
});
}); });

View File

@@ -0,0 +1,249 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { RateLimiter } from '../../../src/lib/rate-limiter.js';
import type { Request } from 'express';
describe('RateLimiter', () => {
let rateLimiter: RateLimiter;
beforeEach(() => {
rateLimiter = new RateLimiter({
maxAttempts: 3,
windowMs: 60000, // 1 minute
blockDurationMs: 60000, // 1 minute
});
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('getClientIp', () => {
it('should extract IP from x-forwarded-for header', () => {
const req = {
headers: { 'x-forwarded-for': '192.168.1.100' },
socket: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
});
it('should use first IP from x-forwarded-for with multiple IPs', () => {
const req = {
headers: { 'x-forwarded-for': '192.168.1.100, 10.0.0.1, 172.16.0.1' },
socket: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
});
it('should fall back to socket remoteAddress when no x-forwarded-for', () => {
const req = {
headers: {},
socket: { remoteAddress: '127.0.0.1' },
} as unknown as Request;
expect(rateLimiter.getClientIp(req)).toBe('127.0.0.1');
});
it('should return "unknown" when no IP can be determined', () => {
const req = {
headers: {},
socket: { remoteAddress: undefined },
} as unknown as Request;
expect(rateLimiter.getClientIp(req)).toBe('unknown');
});
});
describe('isBlocked', () => {
it('should return false for unknown keys', () => {
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
});
it('should return false after recording fewer failures than max', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
});
it('should return true after reaching max failures', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
});
it('should return false after block expires', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
// Advance time past block duration
vi.advanceTimersByTime(60001);
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
});
});
describe('recordFailure', () => {
it('should return false when not yet blocked', () => {
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
});
it('should return true when threshold is reached', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(true);
});
it('should reset counter after window expires', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
// Advance time past window
vi.advanceTimersByTime(60001);
// Should start fresh
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
});
it('should track different IPs independently', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.2');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
expect(rateLimiter.isBlocked('192.168.1.2')).toBe(false);
});
});
describe('reset', () => {
it('should clear record for a key', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.reset('192.168.1.1');
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
});
it('should clear blocked status', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
rateLimiter.reset('192.168.1.1');
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
});
});
describe('getAttemptsRemaining', () => {
it('should return max attempts for unknown key', () => {
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
});
it('should decrease as failures are recorded', () => {
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(1);
rateLimiter.recordFailure('192.168.1.1');
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(0);
});
it('should return max attempts after window expires', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(60001);
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
});
});
describe('getBlockTimeRemaining', () => {
it('should return 0 for non-blocked key', () => {
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
});
it('should return remaining block time for blocked key', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(30000); // Advance 30 seconds
const remaining = rateLimiter.getBlockTimeRemaining('192.168.1.1');
expect(remaining).toBeGreaterThan(29000);
expect(remaining).toBeLessThanOrEqual(30000);
});
it('should return 0 after block expires', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(60001);
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
});
});
describe('cleanup', () => {
it('should remove expired blocks', () => {
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(60001);
rateLimiter.cleanup();
// After cleanup, the record should be gone
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
});
it('should remove expired windows', () => {
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(60001);
rateLimiter.cleanup();
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
});
it('should preserve active records', () => {
rateLimiter.recordFailure('192.168.1.1');
vi.advanceTimersByTime(30000); // Half the window
rateLimiter.cleanup();
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
});
});
describe('default configuration', () => {
it('should use sensible defaults', () => {
const defaultLimiter = new RateLimiter();
// Should have 5 max attempts by default
expect(defaultLimiter.getAttemptsRemaining('test')).toBe(5);
});
});
});

View File

@@ -179,7 +179,7 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => { it('should create options with chat settings', async () => {
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true }); const options = createChatOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path'); expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.standard); expect(options.maxTurns).toBe(MAX_TURNS.standard);
@@ -212,27 +212,6 @@ describe('sdk-options.ts', () => {
expect(options.model).toBe('claude-sonnet-4-20250514'); expect(options.model).toBe('claude-sonnet-4-20250514');
}); });
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
}); });
describe('createAutoModeOptions', () => { describe('createAutoModeOptions', () => {
@@ -240,7 +219,7 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
await import('@/lib/sdk-options.js'); await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true }); const options = createAutoModeOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path'); expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.maximum); expect(options.maxTurns).toBe(MAX_TURNS.maximum);
@@ -273,27 +252,6 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController); expect(options.abortController).toBe(abortController);
}); });
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should not set sandbox when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
});
expect(options.sandbox).toBeUndefined();
});
}); });
describe('createCustomOptions', () => { describe('createCustomOptions', () => {

View File

@@ -1,365 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
import type { SettingsService } from '@/services/settings-service.js';
describe('settings-helpers.ts', () => {
describe('getMCPServersFromSettings', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should return empty object when settingsService is null', async () => {
const result = await getMCPServersFromSettings(null);
expect(result).toEqual({});
});
it('should return empty object when settingsService is undefined', async () => {
const result = await getMCPServersFromSettings(undefined);
expect(result).toEqual({});
});
it('should return empty object when no MCP servers configured', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should return empty object when mcpServers is undefined', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should convert enabled stdio server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'test-server',
type: 'stdio',
command: 'node',
args: ['server.js'],
env: { NODE_ENV: 'test' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'test-server': {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: { NODE_ENV: 'test' },
},
});
});
it('should convert enabled SSE server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'sse-server',
type: 'sse',
url: 'http://localhost:3000/sse',
headers: { Authorization: 'Bearer token' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'sse-server': {
type: 'sse',
url: 'http://localhost:3000/sse',
headers: { Authorization: 'Bearer token' },
},
});
});
it('should convert enabled HTTP server to SDK format', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'http-server',
type: 'http',
url: 'http://localhost:3000/api',
headers: { 'X-API-Key': 'secret' },
enabled: true,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({
'http-server': {
type: 'http',
url: 'http://localhost:3000/api',
headers: { 'X-API-Key': 'secret' },
},
});
});
it('should filter out disabled servers', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'enabled-server',
type: 'stdio',
command: 'node',
enabled: true,
},
{
id: '2',
name: 'disabled-server',
type: 'stdio',
command: 'python',
enabled: false,
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(Object.keys(result)).toHaveLength(1);
expect(result['enabled-server']).toBeDefined();
expect(result['disabled-server']).toBeUndefined();
});
it('should treat servers without enabled field as enabled', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'implicit-enabled',
type: 'stdio',
command: 'node',
// enabled field not set
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result['implicit-enabled']).toBeDefined();
});
it('should handle multiple enabled servers', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(Object.keys(result)).toHaveLength(2);
expect(result['server1']).toBeDefined();
expect(result['server2']).toBeDefined();
});
it('should return empty object and log error on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
expect(result).toEqual({});
expect(console.error).toHaveBeenCalled();
});
it('should throw error for SSE server without URL', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-sse',
type: 'sse',
enabled: true,
// url missing
},
],
}),
} as unknown as SettingsService;
// The error is caught and logged, returns empty
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should throw error for HTTP server without URL', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-http',
type: 'http',
enabled: true,
// url missing
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should throw error for stdio server without command', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'bad-stdio',
type: 'stdio',
enabled: true,
// command missing
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result).toEqual({});
});
it('should default to stdio type when type is not specified', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpServers: [
{
id: '1',
name: 'no-type',
command: 'node',
enabled: true,
// type not specified, should default to stdio
},
],
}),
} as unknown as SettingsService;
const result = await getMCPServersFromSettings(mockSettingsService);
expect(result['no-type']).toEqual({
type: 'stdio',
command: 'node',
args: undefined,
env: undefined,
});
});
});
describe('getMCPPermissionSettings', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should return defaults when settingsService is null', async () => {
const result = await getMCPPermissionSettings(null);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should return defaults when settingsService is undefined', async () => {
const result = await getMCPPermissionSettings(undefined);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should return settings from service', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: false,
mcpUnrestrictedTools: false,
}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: false,
mcpUnrestrictedTools: false,
});
});
it('should default to true when settings are undefined', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
});
it('should handle mixed settings', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: false,
}),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService);
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: false,
});
});
it('should return defaults and log error on exception', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
} as unknown as SettingsService;
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
expect(result).toEqual({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
});
expect(console.error).toHaveBeenCalled();
});
it('should use custom log prefix', async () => {
const mockSettingsService = {
getGlobalSettings: vi.fn().mockResolvedValue({
mcpAutoApproveTools: true,
mcpUnrestrictedTools: true,
}),
} as unknown as SettingsService;
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[CustomPrefix]'));
});
});
});

View File

@@ -73,7 +73,7 @@ describe('claude-provider.ts', () => {
maxTurns: 10, maxTurns: 10,
cwd: '/test/dir', cwd: '/test/dir',
allowedTools: ['Read', 'Write'], allowedTools: ['Read', 'Write'],
permissionMode: 'default', permissionMode: 'acceptEdits',
}), }),
}); });
}); });
@@ -100,7 +100,7 @@ describe('claude-provider.ts', () => {
}); });
}); });
it('should pass sandbox configuration when provided', async () => { it('should enable sandbox by default', async () => {
vi.mocked(sdk.query).mockReturnValue( vi.mocked(sdk.query).mockReturnValue(
(async function* () { (async function* () {
yield { type: 'text', text: 'test' }; yield { type: 'text', text: 'test' };
@@ -110,10 +110,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({ const generator = provider.executeQuery({
prompt: 'Test', prompt: 'Test',
cwd: '/test', cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}); });
await collectAsyncGenerator(generator); await collectAsyncGenerator(generator);
@@ -246,21 +242,11 @@ describe('claude-provider.ts', () => {
}); });
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed'); await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
expect(consoleErrorSpy).toHaveBeenCalledWith(
// Should log error message '[ClaudeProvider] executeQuery() error during execution:',
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
1,
'[ClaudeProvider] ERROR: executeQuery() error during execution:',
testError testError
); );
// Should log stack trace
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
2,
'[ClaudeProvider] ERROR stack:',
testError.stack
);
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
}); });
}); });

View File

@@ -1,499 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js';
import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js';
import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js';
import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js';
import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js';
import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js';
import type { PipelineService } from '@/services/pipeline-service.js';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { createMockExpressContext } from '../../utils/mocks.js';
describe('pipeline routes', () => {
let mockPipelineService: PipelineService;
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
mockPipelineService = {
getPipelineConfig: vi.fn(),
savePipelineConfig: vi.fn(),
addStep: vi.fn(),
updateStep: vi.fn(),
deleteStep: vi.fn(),
reorderSteps: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('get-config', () => {
it('should return pipeline config successfully', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config);
req.body = { projectPath: '/test/project' };
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project');
expect(res.json).toHaveBeenCalledWith({
success: true,
config,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = {};
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
const error = new Error('Read failed');
vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error);
req.body = { projectPath: '/test/project' };
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Read failed',
});
});
});
describe('save-config', () => {
it('should save pipeline config successfully', async () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', config };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config);
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { config: { version: 1, steps: [] } };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if config is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'config is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Save failed');
vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
config: { version: 1, steps: [] },
};
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Save failed',
});
});
});
describe('add-step', () => {
it('should add step successfully', async () => {
const stepData = {
name: 'New Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
};
const newStep: PipelineStep = {
...stepData,
id: 'step1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep);
req.body = { projectPath: '/test/project', step: stepData };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData);
expect(res.json).toHaveBeenCalledWith({
success: true,
step: newStep,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if step is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step is required',
});
});
it('should return 400 if step.name is missing', async () => {
req.body = {
projectPath: '/test/project',
step: { order: 0, instructions: 'Do', colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step.name is required',
});
});
it('should return 400 if step.instructions is missing', async () => {
req.body = {
projectPath: '/test/project',
step: { name: 'Step', order: 0, colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step.instructions is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Add failed');
vi.mocked(mockPipelineService.addStep).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Add failed',
});
});
});
describe('update-step', () => {
it('should update step successfully', async () => {
const updates = {
name: 'Updated Name',
instructions: 'Updated instructions',
};
const updatedStep: PipelineStep = {
id: 'step1',
name: 'Updated Name',
order: 0,
instructions: 'Updated instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
};
vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep);
req.body = { projectPath: '/test/project', stepId: 'step1', updates };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.updateStep).toHaveBeenCalledWith(
'/test/project',
'step1',
updates
);
expect(res.json).toHaveBeenCalledWith({
success: true,
step: updatedStep,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepId: 'step1', updates: { name: 'New' } };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepId is missing', async () => {
req.body = { projectPath: '/test/project', updates: { name: 'New' } };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepId is required',
});
});
it('should return 400 if updates is missing', async () => {
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'updates is required',
});
});
it('should return 400 if updates is empty object', async () => {
req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'updates is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Update failed');
vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
stepId: 'step1',
updates: { name: 'New' },
};
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Update failed',
});
});
});
describe('delete-step', () => {
it('should delete step successfully', async () => {
vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1');
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepId is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepId is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Delete failed');
vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error);
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Delete failed',
});
});
});
describe('reorder-steps', () => {
it('should reorder steps successfully', async () => {
vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [
'step2',
'step1',
'step3',
]);
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepIds: ['step1', 'step2'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepIds is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepIds array is required',
});
});
it('should return 400 if stepIds is not an array', async () => {
req.body = { projectPath: '/test/project', stepIds: 'not-an-array' };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepIds array is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Reorder failed');
vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error);
req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Reorder failed',
});
});
});
});

View File

@@ -106,9 +106,9 @@ describe('agent-service.ts', () => {
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
// First call reads session file, metadata file, and queue state file (3 calls) // First call reads session file and metadata file (2 calls)
// Second call should reuse in-memory session (no additional calls) // Second call should reuse in-memory session (no additional calls)
expect(fs.readFile).toHaveBeenCalledTimes(3); expect(fs.readFile).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@@ -1,860 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { PipelineService } from '@/services/pipeline-service.js';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
rename: vi.fn(),
unlink: vi.fn(),
}));
// Mock ensureAutomakerDir
vi.mock('@automaker/platform', () => ({
ensureAutomakerDir: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import { ensureAutomakerDir } from '@automaker/platform';
describe('pipeline-service.ts', () => {
let testProjectDir: string;
let pipelineService: PipelineService;
beforeEach(async () => {
testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`);
await fs.mkdir(testProjectDir, { recursive: true });
pipelineService = new PipelineService();
vi.clearAllMocks();
});
afterEach(async () => {
try {
await fs.rm(testProjectDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getPipelineConfig', () => {
it('should return default config when file does not exist', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(secureFs.readFile).mockRejectedValue(error);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(config).toEqual({
version: 1,
steps: [],
});
});
it('should read and return existing config', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8');
expect(config).toEqual(existingConfig);
});
it('should merge with defaults for missing properties', async () => {
const partialConfig = {
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(config.version).toBe(1);
expect(config.steps).toHaveLength(1);
});
it('should handle read errors gracefully', async () => {
const error = new Error('Read error');
vi.mocked(secureFs.readFile).mockRejectedValue(error);
const config = await pipelineService.getPipelineConfig(testProjectDir);
// Should return default config on error
expect(config).toEqual({
version: 1,
steps: [],
});
});
});
describe('savePipelineConfig', () => {
it('should save config to file', async () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.savePipelineConfig(testProjectDir, config);
expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir);
expect(secureFs.writeFile).toHaveBeenCalled();
expect(secureFs.rename).toHaveBeenCalled();
});
it('should use atomic write pattern', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.savePipelineConfig(testProjectDir, config);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const tempPath = writeCall[0] as string;
expect(tempPath).toContain('.tmp.');
expect(tempPath).toContain('pipeline.json');
});
it('should clean up temp file on write error', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
vi.mocked(secureFs.unlink).mockResolvedValue(undefined);
await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow(
'Write failed'
);
expect(secureFs.unlink).toHaveBeenCalled();
});
});
describe('addStep', () => {
it('should add a new step to config', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(secureFs.readFile).mockRejectedValue(error);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
};
const newStep = await pipelineService.addStep(testProjectDir, stepData);
expect(newStep.name).toBe('New Step');
expect(newStep.id).toMatch(/^step_/);
expect(newStep.createdAt).toBeDefined();
expect(newStep.updatedAt).toBeDefined();
expect(newStep.createdAt).toBe(newStep.updatedAt);
});
it('should normalize order values after adding step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 5, // Out of order
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 10, // Out of order
instructions: 'Do something',
colorClass: 'red',
};
await pipelineService.addStep(testProjectDir, stepData);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
});
it('should sort steps by order before normalizing', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 2,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 0,
instructions: 'Do something else',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 1,
instructions: 'Do something',
colorClass: 'red',
};
await pipelineService.addStep(testProjectDir, stepData);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
// Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2)
expect(savedConfig.steps[0].id).toBe('step2');
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
expect(savedConfig.steps[2].id).toBe('step1');
expect(savedConfig.steps[2].order).toBe(2);
});
});
describe('updateStep', () => {
it('should update an existing step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Old Name',
order: 0,
instructions: 'Old instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const updates = {
name: 'New Name',
instructions: 'New instructions',
};
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates);
expect(updatedStep.name).toBe('New Name');
expect(updatedStep.instructions).toBe('New instructions');
expect(updatedStep.id).toBe('step1');
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
});
it('should throw error if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(
pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' })
).rejects.toThrow('Pipeline step not found: nonexistent');
});
it('should preserve createdAt when updating', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', {
name: 'Updated',
});
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
});
});
describe('deleteStep', () => {
it('should delete an existing step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.deleteStep(testProjectDir, 'step1');
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps).toHaveLength(1);
expect(savedConfig.steps[0].id).toBe('step2');
expect(savedConfig.steps[0].order).toBe(0); // Normalized
});
it('should throw error if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow(
'Pipeline step not found: nonexistent'
);
});
it('should normalize order values after deletion', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 5, // Out of order
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 10, // Out of order
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.deleteStep(testProjectDir, 'step2');
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps).toHaveLength(2);
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
});
});
describe('reorderSteps', () => {
it('should reorder steps according to stepIds array', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 2,
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].id).toBe('step3');
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].id).toBe('step1');
expect(savedConfig.steps[1].order).toBe(1);
expect(savedConfig.steps[2].id).toBe('step2');
expect(savedConfig.steps[2].order).toBe(2);
});
it('should update updatedAt timestamp for reordered steps', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
});
it('should throw error if step ID not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(
pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent'])
).rejects.toThrow('Pipeline step not found: nonexistent');
});
it('should allow partial reordering (filtering steps)', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step1']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
// Should only keep step1, effectively filtering out step2
expect(savedConfig.steps).toHaveLength(1);
expect(savedConfig.steps[0].id).toBe('step1');
expect(savedConfig.steps[0].order).toBe(0);
});
});
describe('getNextStatus', () => {
it('should return waiting_approval when no pipeline and skipTests is true', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', null, true);
expect(nextStatus).toBe('waiting_approval');
});
it('should return verified when no pipeline and skipTests is false', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', null, false);
expect(nextStatus).toBe('verified');
});
it('should return first pipeline step when coming from in_progress', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1');
});
it('should go to next pipeline step when in middle of pipeline', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
expect(nextStatus).toBe('pipeline_step2');
});
it('should go to final status when completing last pipeline step', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
expect(nextStatus).toBe('verified');
});
it('should go to waiting_approval when completing last step with skipTests', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true);
expect(nextStatus).toBe('waiting_approval');
});
it('should handle invalid pipeline step ID gracefully', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false);
expect(nextStatus).toBe('verified');
});
it('should preserve other statuses unchanged', () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog');
expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe(
'waiting_approval'
);
expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified');
expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed');
});
it('should sort steps by order when determining next status', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
});
});
describe('getStep', () => {
it('should return step by ID', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const step = await pipelineService.getStep(testProjectDir, 'step1');
expect(step).not.toBeNull();
expect(step?.id).toBe('step1');
expect(step?.name).toBe('Step 1');
});
it('should return null if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const step = await pipelineService.getStep(testProjectDir, 'nonexistent');
expect(step).toBeNull();
});
});
describe('isPipelineStatus', () => {
it('should return true for pipeline statuses', () => {
expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true);
expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true);
});
it('should return false for non-pipeline statuses', () => {
expect(pipelineService.isPipelineStatus('in_progress')).toBe(false);
expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false);
expect(pipelineService.isPipelineStatus('verified')).toBe(false);
expect(pipelineService.isPipelineStatus('backlog')).toBe(false);
expect(pipelineService.isPipelineStatus('completed')).toBe(false);
});
});
describe('getStepIdFromStatus', () => {
it('should extract step ID from pipeline status', () => {
expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1');
expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123');
});
it('should return null for non-pipeline statuses', () => {
expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull();
expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull();
expect(pipelineService.getStepIdFromStatus('verified')).toBeNull();
});
});
});

View File

@@ -44,7 +44,6 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",

View File

@@ -1,5 +1,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react'; import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
Clock,
X,
} from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,11 +19,9 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PathInput } from '@/components/ui/path-input'; import { Input } from '@/components/ui/input';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage'; import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
@@ -69,8 +77,8 @@ export function FileBrowserDialog({
description = 'Navigate to your project folder or paste a path directly', description = 'Navigate to your project folder or paste a path directly',
initialPath, initialPath,
}: FileBrowserDialogProps) { }: FileBrowserDialogProps) {
const { isMac } = useOSDetection();
const [currentPath, setCurrentPath] = useState<string>(''); const [currentPath, setCurrentPath] = useState<string>('');
const [pathInput, setPathInput] = useState<string>('');
const [parentPath, setParentPath] = useState<string | null>(null); const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]); const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]); const [drives, setDrives] = useState<string[]>([]);
@@ -78,6 +86,7 @@ export function FileBrowserDialog({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [warning, setWarning] = useState(''); const [warning, setWarning] = useState('');
const [recentFolders, setRecentFolders] = useState<string[]>([]); const [recentFolders, setRecentFolders] = useState<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
// Load recent folders when dialog opens // Load recent folders when dialog opens
useEffect(() => { useEffect(() => {
@@ -111,6 +120,7 @@ export function FileBrowserDialog({
if (result.success) { if (result.success) {
setCurrentPath(result.currentPath); setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath); setParentPath(result.parentPath);
setDirectories(result.directories); setDirectories(result.directories);
setDrives(result.drives || []); setDrives(result.drives || []);
@@ -132,10 +142,11 @@ export function FileBrowserDialog({
[browseDirectory] [browseDirectory]
); );
// Reset state when dialog closes // Reset current path when dialog closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setCurrentPath(''); setCurrentPath('');
setPathInput('');
setParentPath(null); setParentPath(null);
setDirectories([]); setDirectories([]);
setError(''); setError('');
@@ -161,6 +172,9 @@ export function FileBrowserDialog({
const pathToUse = initialPath || defaultDir; const pathToUse = initialPath || defaultDir;
if (pathToUse) { if (pathToUse) {
// Pre-fill the path input immediately
setPathInput(pathToUse);
// Then browse to that directory
browseDirectory(pathToUse); browseDirectory(pathToUse);
} else { } else {
// No default directory, browse home directory // No default directory, browse home directory
@@ -169,6 +183,7 @@ export function FileBrowserDialog({
} catch { } catch {
// If config fetch fails, try initialPath or fall back to home directory // If config fetch fails, try initialPath or fall back to home directory
if (initialPath) { if (initialPath) {
setPathInput(initialPath);
browseDirectory(initialPath); browseDirectory(initialPath);
} else { } else {
browseDirectory(); browseDirectory();
@@ -184,21 +199,34 @@ export function FileBrowserDialog({
browseDirectory(dir.path); browseDirectory(dir.path);
}; };
const handleGoHome = useCallback(() => { const handleGoToParent = () => {
browseDirectory(); if (parentPath) {
}, [browseDirectory]); browseDirectory(parentPath);
}
};
const handleNavigate = useCallback( const handleGoHome = () => {
(path: string) => { browseDirectory();
browseDirectory(path); };
},
[browseDirectory]
);
const handleSelectDrive = (drivePath: string) => { const handleSelectDrive = (drivePath: string) => {
browseDirectory(drivePath); browseDirectory(drivePath);
}; };
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = useCallback(() => { const handleSelect = useCallback(() => {
if (currentPath) { if (currentPath) {
addRecentFolder(currentPath); addRecentFolder(currentPath);
@@ -235,7 +263,7 @@ export function FileBrowserDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none"> <DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
<DialogHeader className="pb-1"> <DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" /> <FolderOpen className="w-4 h-4 text-brand-500" />
@@ -247,21 +275,31 @@ export function FileBrowserDialog({
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1"> <div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
{/* Path navigation */} {/* Direct path input */}
<PathInput <div className="flex items-center gap-1.5">
currentPath={currentPath} <Input
parentPath={parentPath} ref={pathInputRef}
loading={loading} type="text"
error={!!error} placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
onNavigate={handleNavigate} value={pathInput}
onHome={handleGoHome} onChange={(e) => setPathInput(e.target.value)}
entries={directories.map((dir) => ({ ...dir, isDirectory: true }))} onKeyDown={handlePathInputKeyDown}
onSelectEntry={(entry) => { className="flex-1 font-mono text-xs h-8"
if (entry.isDirectory) { data-testid="path-input"
handleSelectDirectory(entry); disabled={loading}
} />
}} <Button
/> variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
className="h-8 px-2"
>
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
Go
</Button>
</div>
{/* Recent folders */} {/* Recent folders */}
{recentFolders.length > 0 && ( {recentFolders.length > 0 && (
@@ -314,8 +352,35 @@ export function FileBrowserDialog({
</div> </div>
)} )}
{/* Current path breadcrumb */}
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-6 px-1.5"
disabled={loading}
>
<Home className="w-3.5 h-3.5" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-6 px-1.5"
disabled={loading}
>
<ArrowLeft className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || 'Loading...'}
</div>
</div>
{/* Directory list */} {/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled"> <div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && ( {loading && (
<div className="flex items-center justify-center h-full p-4"> <div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">Loading directories...</div> <div className="text-xs text-muted-foreground">Loading directories...</div>
@@ -358,8 +423,8 @@ export function FileBrowserDialog({
</div> </div>
<div className="text-[10px] text-muted-foreground"> <div className="text-[10px] text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press Enter or click to jump Paste a full path above, or click on folders to navigate. Press Enter or click Go to
to a path. jump to a path.
</div> </div>
</div> </div>
@@ -375,10 +440,12 @@ export function FileBrowserDialog({
> >
<FolderOpen className="w-3.5 h-3.5 mr-1.5" /> <FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder Select Current Folder
<KbdGroup className="ml-1"> <kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd> {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
<Kbd></Kbd> ? '⌘'
</KbdGroup> : 'Ctrl'}
+
</kbd>
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -28,7 +28,7 @@ import {
useNavigation, useNavigation,
useProjectCreation, useProjectCreation,
useSetupDialog, useSetupDialog,
useTrashOperations, useTrashDialog,
useProjectTheme, useProjectTheme,
useUnviewedValidations, useUnviewedValidations,
} from './sidebar/hooks'; } from './sidebar/hooks';
@@ -68,9 +68,6 @@ export function Sidebar() {
// State for delete project confirmation dialog // State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme) // Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme(); const { globalTheme } = useProjectTheme();
@@ -134,17 +131,20 @@ export function Sidebar() {
// Unviewed validations count // Unviewed validations count
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash operations // Trash dialog and operations
const { const {
showTrashDialog,
setShowTrashDialog,
activeTrashId, activeTrashId,
isEmptyingTrash, isEmptyingTrash,
handleRestoreProject, handleRestoreProject,
handleDeleteProjectFromDisk, handleDeleteProjectFromDisk,
handleEmptyTrash, handleEmptyTrash,
} = useTrashOperations({ } = useTrashDialog({
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
trashedProjects,
}); });
// Spec regeneration events // Spec regeneration events

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { X, Trash2, Undo2 } from 'lucide-react'; import { X, Trash2, Undo2 } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -9,8 +8,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { TrashedProject } from '@/lib/electron'; import type { TrashedProject } from '@/lib/electron';
interface TrashDialogProps { interface TrashDialogProps {
@@ -36,146 +33,84 @@ export function TrashDialog({
handleEmptyTrash, handleEmptyTrash,
isEmptyingTrash, isEmptyingTrash,
}: TrashDialogProps) { }: TrashDialogProps) {
// Confirmation dialog state (managed internally to avoid prop drilling)
const [deleteFromDiskProject, setDeleteFromDiskProject] = useState<TrashedProject | null>(null);
const [showEmptyTrashConfirm, setShowEmptyTrashConfirm] = useState(false);
// Reset confirmation dialog state when main dialog closes
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setDeleteFromDiskProject(null);
setShowEmptyTrashConfirm(false);
}
onOpenChange(isOpen);
};
const onDeleteFromDiskClick = (project: TrashedProject) => {
setDeleteFromDiskProject(project);
};
const onConfirmDeleteFromDisk = () => {
if (deleteFromDiskProject) {
handleDeleteProjectFromDisk(deleteFromDiskProject);
setDeleteFromDiskProject(null);
}
};
const onEmptyTrashClick = () => {
setShowEmptyTrashConfirm(true);
};
const onConfirmEmptyTrash = () => {
handleEmptyTrash();
setShowEmptyTrashConfirm(false);
};
return ( return (
<> <Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}> <DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl"> <DialogHeader>
<DialogHeader> <DialogTitle>Recycle Bin</DialogTitle>
<DialogTitle>Recycle Bin</DialogTitle> <DialogDescription className="text-muted-foreground">
<DialogDescription className="text-muted-foreground"> Restore projects to the sidebar or delete their folders using your system Trash.
Restore projects to the sidebar or delete their folders using your system Trash. </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader>
{trashedProjects.length === 0 ? ( {trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p> <p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : ( ) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => ( {trashedProjects.map((project) => (
<div <div
key={project.id} key={project.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4" className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDeleteFromDiskClick(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={onEmptyTrashClick}
disabled={isEmptyingTrash}
data-testid="empty-trash"
> >
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'} <div className="space-y-1 min-w-0">
</Button> <p className="text-sm font-medium text-foreground truncate">{project.name}</p>
)} <p className="text-xs text-muted-foreground break-all">{project.path}</p>
</DialogFooter> <p className="text-[11px] text-muted-foreground/80">
</DialogContent> Trashed {new Date(project.trashedAt).toLocaleString()}
</Dialog> </p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
{/* Delete from disk confirmation dialog */} <DialogFooter className="flex justify-between">
{deleteFromDiskProject && ( <Button variant="ghost" onClick={() => onOpenChange(false)}>
<DeleteConfirmDialog Close
open </Button>
onOpenChange={(isOpen) => !isOpen && setDeleteFromDiskProject(null)} {trashedProjects.length > 0 && (
onConfirm={onConfirmDeleteFromDisk} <Button
title={`Delete "${deleteFromDiskProject.name}" from disk?`} variant="outline"
description="This sends the folder to your system Trash." onClick={handleEmptyTrash}
confirmText="Delete from disk" disabled={isEmptyingTrash}
testId="delete-from-disk-confirm-dialog" data-testid="empty-trash"
confirmTestId="confirm-delete-from-disk-button" >
/> {isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
)} </Button>
)}
{/* Empty trash confirmation dialog */} </DialogFooter>
<ConfirmDialog </DialogContent>
open={showEmptyTrashConfirm} </Dialog>
onOpenChange={setShowEmptyTrashConfirm}
onConfirm={onConfirmEmptyTrash}
title="Empty Recycle Bin"
description="Clear all projects from recycle bin? This does not delete folders from disk."
confirmText="Empty"
confirmVariant="destructive"
icon={Trash2}
iconClassName="text-destructive"
/>
</>
); );
} }

View File

@@ -8,5 +8,6 @@ export { useSpecRegeneration } from './use-spec-regeneration';
export { useNavigation } from './use-navigation'; export { useNavigation } from './use-navigation';
export { useProjectCreation } from './use-project-creation'; export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog'; export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme'; export { useProjectTheme } from './use-project-theme';
export { useUnviewedValidations } from './use-unviewed-validations'; export { useUnviewedValidations } from './use-unviewed-validations';

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTrashOperations } from './use-trash-operations';
import type { TrashedProject } from '@/lib/electron';
interface UseTrashDialogProps {
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
/**
* Hook that combines trash operations with dialog state management
*/
export function useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashDialogProps) {
// Dialog state
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Reuse existing trash operations logic
const trashOperations = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
return {
// Dialog state
showTrashDialog,
setShowTrashDialog,
// Trash operations (spread from existing hook)
...trashOperations,
};
}

View File

@@ -6,35 +6,35 @@ interface UseTrashOperationsProps {
restoreTrashedProject: (projectId: string) => void; restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void; deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void; emptyTrash: () => void;
trashedProjects: TrashedProject[];
} }
export function useTrashOperations({ export function useTrashOperations({
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
trashedProjects,
}: UseTrashOperationsProps) { }: UseTrashOperationsProps) {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null); const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
const handleRestoreProject = useCallback( const handleRestoreProject = useCallback(
(projectId: string) => { (projectId: string) => {
try { restoreTrashedProject(projectId);
restoreTrashedProject(projectId); toast.success('Project restored', {
toast.success('Project restored', { description: 'Added back to your project list.',
description: 'Added back to your project list.', });
});
} catch (error) {
console.error('[Sidebar] Failed to restore project:', error);
toast.error('Failed to restore project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, },
[restoreTrashedProject] [restoreTrashedProject]
); );
const handleDeleteProjectFromDisk = useCallback( const handleDeleteProjectFromDisk = useCallback(
async (trashedProject: TrashedProject) => { async (trashedProject: TrashedProject) => {
const confirmed = window.confirm(
`Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.`
);
if (!confirmed) return;
setActiveTrashId(trashedProject.id); setActiveTrashId(trashedProject.id);
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
@@ -64,19 +64,23 @@ export function useTrashOperations({
); );
const handleEmptyTrash = useCallback(() => { const handleEmptyTrash = useCallback(() => {
if (trashedProjects.length === 0) {
return;
}
const confirmed = window.confirm(
'Clear all projects from recycle bin? This does not delete folders from disk.'
);
if (!confirmed) return;
setIsEmptyingTrash(true); setIsEmptyingTrash(true);
try { try {
emptyTrash(); emptyTrash();
toast.success('Recycle bin cleared'); toast.success('Recycle bin cleared');
} catch (error) {
console.error('[Sidebar] Failed to empty trash:', error);
toast.error('Failed to clear recycle bin', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally { } finally {
setIsEmptyingTrash(false); setIsEmptyingTrash(false);
} }
}, [emptyTrash]); }, [emptyTrash, trashedProjects.length]);
return { return {
activeTrashId, activeTrashId,

View File

@@ -1,102 +0,0 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -1,10 +0,0 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,28 +0,0 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10',
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -1,442 +0,0 @@
import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { Home, ArrowLeft, Pencil, ArrowRight, Search, Folder, File, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Kbd } from '@/components/ui/kbd';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
interface BreadcrumbSegment {
name: string;
path: string;
isLast: boolean;
}
function parseBreadcrumbs(path: string): BreadcrumbSegment[] {
if (!path) return [];
// Handle root path on Unix-like systems
if (path === '/') {
return [{ name: '/', path: '/', isLast: true }];
}
const segments = path.split(/[/\\]/).filter(Boolean);
const isWindows = segments[0]?.includes(':');
return segments.map((segment, index) => {
let fullPath: string;
if (isWindows) {
const pathParts = segments.slice(0, index + 1);
if (index === 0) {
fullPath = `${pathParts[0]}\\`;
} else {
fullPath = pathParts.join('\\');
}
} else {
fullPath = '/' + segments.slice(0, index + 1).join('/');
}
return {
name: segment,
path: fullPath,
isLast: index === segments.length - 1,
};
});
}
interface FileSystemEntry {
name: string;
path: string;
isDirectory: boolean;
}
interface PathInputProps {
/** Current resolved path */
currentPath: string;
/** Parent path for back navigation (null if at root) */
parentPath: string | null;
/** Whether the component is in a loading state */
loading?: boolean;
/** Whether there's an error (shows input mode and red border when true) */
error?: boolean;
/** Placeholder text for the input field */
placeholder?: string;
/** Placeholder text for the search input field */
searchPlaceholder?: string;
/** Called when user navigates to a path (via breadcrumb click, enter key, or navigation buttons) */
onNavigate: (path: string) => void;
/** Called when user clicks home button (navigates to home directory) */
onHome: () => void;
/** Additional className for the container */
className?: string;
/** List of files and directories in current path for search functionality */
entries?: FileSystemEntry[];
/** Called when user selects an entry from search results */
onSelectEntry?: (entry: FileSystemEntry) => void;
}
function PathInput({
currentPath,
parentPath,
loading = false,
error,
placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)',
searchPlaceholder = 'Search...',
onNavigate,
onHome,
className,
entries = [],
onSelectEntry,
}: PathInputProps) {
const [isEditing, setIsEditing] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [pathInput, setPathInput] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Sync pathInput with currentPath when it changes externally
useEffect(() => {
if (!isEditing) {
setPathInput(currentPath);
}
}, [currentPath, isEditing]);
// Focus input when error occurs or entering edit mode
useEffect(() => {
if ((error || isEditing) && inputRef.current) {
inputRef.current.focus();
if (error) {
inputRef.current.select();
}
}
}, [error, isEditing]);
const handleGoToParent = useCallback(() => {
if (parentPath) {
onNavigate(parentPath);
}
}, [parentPath, onNavigate]);
const handleBreadcrumbClick = useCallback(
(path: string) => {
onNavigate(path);
},
[onNavigate]
);
const handleStartEditing = useCallback(() => {
setIsEditing(true);
}, []);
const handleInputBlur = useCallback(
(e: FocusEvent) => {
// Check if focus is moving to another element within this component
if (containerRef.current?.contains(e.relatedTarget)) {
return;
}
if (pathInput !== currentPath) {
setPathInput(currentPath);
}
setIsEditing(false);
},
[pathInput, currentPath]
);
const handleGoToPath = useCallback(() => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
onNavigate(trimmedPath);
setIsEditing(false);
}
}, [pathInput, onNavigate]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleGoToPath();
} else if (e.key === 'Escape') {
e.preventDefault();
setPathInput(currentPath);
setIsEditing(false);
inputRef.current?.blur();
}
},
[handleGoToPath, currentPath]
);
// Handle click on the path container to start editing
const handleContainerClick = useCallback(
(e: MouseEvent) => {
// Don't trigger if clicking on a button or already editing
if (
isEditing ||
isSearchOpen ||
(e.target as HTMLElement).closest('button') ||
(e.target as HTMLElement).closest('a')
) {
return;
}
setIsEditing(true);
},
[isEditing, isSearchOpen]
);
const handleSelectEntry = useCallback(
(entry: FileSystemEntry) => {
if (onSelectEntry) {
onSelectEntry(entry);
}
setIsSearchOpen(false);
},
[onSelectEntry]
);
// Global keyboard shortcut to activate search (/)
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
// Activate search with '/' key (unless in an input field or contenteditable)
if (
e.key === '/' &&
!isEditing &&
!isSearchOpen &&
entries.length > 0 &&
!(e.target as HTMLElement).matches('input, textarea, [contenteditable="true"]')
) {
e.preventDefault();
setIsSearchOpen(true);
}
// Close search with Escape key
if (e.key === 'Escape' && isSearchOpen) {
e.preventDefault();
e.stopPropagation(); // Stop propagation so parent modal doesn't close
setIsSearchOpen(false);
}
};
// Use capture phase to intercept ESC before parent modal handlers
// This allows us to close search first, then let ESC bubble to close modal on second press
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [isEditing, isSearchOpen, entries.length]);
// Close search when clicking outside
useEffect(() => {
if (!isSearchOpen) return;
const handleClickOutside = (e: globalThis.MouseEvent) => {
const target = e.target as HTMLElement;
if (containerRef.current && !containerRef.current.contains(target)) {
setIsSearchOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isSearchOpen]);
const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]);
const entryItems = useMemo(
() =>
entries.map((entry) => (
<CommandItem key={entry.path} value={entry.name} onSelect={() => handleSelectEntry(entry)}>
{entry.isDirectory ? (
<Folder className="w-3.5 h-3.5 text-brand-500 mr-2" />
) : (
<File className="w-3.5 h-3.5 text-muted-foreground mr-2" />
)}
<span className="flex-1 truncate font-mono text-xs">{entry.name}</span>
</CommandItem>
)),
[entries, handleSelectEntry]
);
const showBreadcrumbs = currentPath && !isEditing && !loading && !error;
return (
<div
ref={containerRef}
className={cn('flex items-center gap-2', className)}
role="navigation"
aria-label="Path navigation"
>
{/* Navigation buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={onHome}
className="h-7 w-7"
disabled={loading}
aria-label="Go to home directory"
>
<Home className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleGoToParent}
className="h-7 w-7"
disabled={loading || !parentPath}
aria-label="Go to parent directory"
>
<ArrowLeft className="w-4 h-4" />
</Button>
</div>
{/* Path display / input */}
<div className="flex-1 relative min-w-0">
{/* Search Popover - positioned to overlap the input */}
{isSearchOpen && (
<div className="absolute inset-0 z-50">
<div className="relative w-full h-full">
<div className="absolute inset-0 bg-popover border border-border rounded-md shadow-lg">
<Command className="h-auto max-h-[300px]">
<div className="flex items-center gap-2 px-3 **:data-[slot=command-input-wrapper]:border-0 **:data-[slot=command-input-wrapper]:px-0">
<CommandInput
autoFocus
placeholder={searchPlaceholder}
className="h-8 flex-1"
/>
<div className="flex items-center gap-1 shrink-0 ml-auto">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchOpen(false)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Close search"
>
<X className="w-3.5 h-3.5" />
</Button>
<Kbd className="hidden py-0.5 sm:inline-block">ESC</Kbd>
</div>
</div>
<CommandList className="scrollbar-styled">
<CommandEmpty>No files or directories found</CommandEmpty>
<CommandGroup>{entryItems}</CommandGroup>
</CommandList>
</Command>
</div>
</div>
</div>
)}
<div
onClick={handleContainerClick}
className={cn(
'flex items-center gap-2 min-w-0 h-8 px-3 rounded-md border bg-background/50 transition-colors',
error
? 'border-destructive focus-within:border-destructive'
: 'border-input focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
!isEditing && !error && 'cursor-text hover:border-ring/50'
)}
>
{showBreadcrumbs ? (
<>
<Breadcrumb className="flex-1 min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap overflow-x-auto scrollbar-none">
{breadcrumbs.map((crumb) => (
<Fragment key={crumb.path}>
<BreadcrumbItem className="shrink-0">
{crumb.isLast ? (
<BreadcrumbPage className="font-mono text-xs font-medium truncate max-w-[200px]">
{crumb.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink
href="#"
onClick={(e) => {
e.preventDefault();
handleBreadcrumbClick(crumb.path);
}}
className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]"
>
{crumb.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && (
<BreadcrumbSeparator className="[&>svg]:size-3.5 shrink-0" />
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setIsSearchOpen(true)}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Search files and directories"
title="Search files and directories"
disabled={loading || entries.length === 0}
>
<Search className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleStartEditing}
className="h-6 w-6 text-muted-foreground hover:text-foreground"
aria-label="Edit path"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
</div>
</>
) : (
<>
<Input
ref={inputRef}
type="text"
placeholder={placeholder}
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent"
data-testid="path-input"
disabled={loading}
aria-label="Path input"
aria-invalid={error}
/>
<Button
variant="ghost"
size="icon"
onClick={handleGoToPath}
disabled={!pathInput.trim() || loading}
className="h-6 w-6 shrink-0"
aria-label="Go to path"
>
<ArrowRight className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
);
}
export { PathInput, parseBreadcrumbs };
export type { PathInputProps, BreadcrumbSegment, FileSystemEntry };

View File

@@ -18,7 +18,6 @@ import {
ChevronDown, ChevronDown,
FileText, FileText,
Square, Square,
ListOrdered,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent'; import { useElectronAgent } from '@/hooks/use-electron-agent';
@@ -87,10 +86,6 @@ export function AgentView() {
clearHistory, clearHistory,
stopExecution, stopExecution,
error: agentError, error: agentError,
serverQueue,
addToServerQueue,
removeFromServerQueue,
clearServerQueue,
} = useElectronAgent({ } = useElectronAgent({
sessionId: currentSessionId || '', sessionId: currentSessionId || '',
workingDirectory: currentProject?.path, workingDirectory: currentProject?.path,
@@ -139,7 +134,11 @@ export function AgentView() {
}, [currentProject?.path]); }, [currentProject?.path]);
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return; if (
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
isProcessing
)
return;
const messageContent = input; const messageContent = input;
const messageImages = selectedImages; const messageImages = selectedImages;
@@ -150,13 +149,8 @@ export function AgentView() {
setSelectedTextFiles([]); setSelectedTextFiles([]);
setShowImageDropZone(false); setShowImageDropZone(false);
// If already processing, add to server queue instead await sendMessage(messageContent, messageImages, messageTextFiles);
if (isProcessing) { }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
await addToServerQueue(messageContent, messageImages, messageTextFiles);
} else {
await sendMessage(messageContent, messageImages, messageTextFiles);
}
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => { const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images); setSelectedImages(images);
@@ -542,6 +536,41 @@ export function AgentView() {
{/* Status indicators & actions */} {/* Status indicators & actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentTool && ( {currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border"> <div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" /> <Wrench className="w-3 h-3 text-primary" />
@@ -731,52 +760,10 @@ export function AgentView() {
images={selectedImages} images={selectedImages}
maxFiles={5} maxFiles={5}
className="mb-4" className="mb-4"
disabled={!isConnected} disabled={isProcessing || !isConnected}
/> />
)} )}
{/* Queued Prompts List */}
{serverQueue.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={clearServerQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => removeFromServerQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && ( {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
<div className="mb-4 space-y-2"> <div className="mb-4 space-y-2">
@@ -791,6 +778,7 @@ export function AgentView() {
setSelectedTextFiles([]); setSelectedTextFiles([]);
}} }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
> >
Clear all Clear all
</button> </button>
@@ -881,17 +869,13 @@ export function AgentView() {
<Input <Input
ref={inputRef} ref={inputRef}
placeholder={ placeholder={
isDragOver isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
? 'Drop your files here...'
: isProcessing
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
} }
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
onPaste={handlePaste} onPaste={handlePaste}
disabled={!isConnected} disabled={isProcessing || !isConnected}
data-testid="agent-input" data-testid="agent-input"
className={cn( className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', 'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
@@ -915,44 +899,12 @@ export function AgentView() {
)} )}
</div> </div>
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5"
data-testid="model-selector"
>
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* File Attachment Button */} {/* File Attachment Button */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={toggleImageDropZone} onClick={toggleImageDropZone}
disabled={!isConnected} disabled={isProcessing || !isConnected}
className={cn( className={cn(
'h-11 w-11 rounded-xl border-border', 'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30', showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
@@ -964,8 +916,8 @@ export function AgentView() {
<Paperclip className="w-4 h-4" /> <Paperclip className="w-4 h-4" />
</Button> </Button>
{/* Stop Button (only when processing) */} {/* Send / Stop Button */}
{isProcessing && ( {isProcessing ? (
<Button <Button
onClick={stopExecution} onClick={stopExecution}
disabled={!isConnected} disabled={!isConnected}
@@ -976,24 +928,21 @@ export function AgentView() {
> >
<Square className="w-4 h-4 fill-current" /> <Square className="w-4 h-4 fill-current" />
</Button> </Button>
) : (
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
)} )}
{/* Send / Queue Button */}
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
!isConnected
}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div> </div>
{/* Keyboard hint */} {/* Keyboard hint */}

View File

@@ -8,11 +8,8 @@ import {
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store'; import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { AutoModeEvent } from '@/types/electron'; import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils'; import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
@@ -28,7 +25,6 @@ import { GraphView } from './graph-view';
import { import {
AddFeatureDialog, AddFeatureDialog,
AgentOutputModal, AgentOutputModal,
BacklogPlanDialog,
CompletedFeaturesModal, CompletedFeaturesModal,
ArchiveAllVerifiedDialog, ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog, DeleteCompletedFeatureDialog,
@@ -37,7 +33,6 @@ import {
FollowUpDialog, FollowUpDialog,
PlanApprovalDialog, PlanApprovalDialog,
} from './board-view/dialogs'; } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog'; import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog'; import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog'; import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
@@ -62,6 +57,9 @@ import {
// Stable empty array to avoid infinite loop in selector // Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = []; const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
export function BoardView() { export function BoardView() {
const { const {
currentProject, currentProject,
@@ -87,10 +85,7 @@ export function BoardView() {
enableDependencyBlocking, enableDependencyBlocking,
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore(); } = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const { const {
features: hookFeatures, features: hookFeatures,
@@ -112,9 +107,6 @@ export function BoardView() {
// State for viewing plan in read-only mode // State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null); const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
// Worktree dialog states // Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false); const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false); const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
@@ -130,14 +122,6 @@ export function BoardView() {
} | null>(null); } | null>(null);
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0); const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Backlog plan dialog state
const [showPlanDialog, setShowPlanDialog] = useState(false);
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
// Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
// Follow-up state hook // Follow-up state hook
const { const {
showFollowUpDialog, showFollowUpDialog,
@@ -209,25 +193,6 @@ export function BoardView() {
setFeaturesWithContext, setFeaturesWithContext,
}); });
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
console.error('[Board] Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Auto mode hook // Auto mode hook
const autoMode = useAutoMode(); const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project) // Get runningTasks from the hook (scoped to current project)
@@ -485,22 +450,23 @@ export function BoardView() {
requirePlanApproval: false, requirePlanApproval: false,
}; };
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData); await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set // Find the newly created feature and start it
const latestFeatures = useAppStore.getState().features; // We need to wait a moment for the feature to be created
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
if (newFeature) { if (newFeature) {
await handleStartImplementation(newFeature); await handleStartImplementation(newFeature);
} else { }
console.error('Could not find newly created feature to start it automatically.'); }, FEATURE_CREATION_SETTLE_DELAY_MS);
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
}, },
[handleAddFeature, handleStartImplementation, defaultSkipTests] [handleAddFeature, handleStartImplementation, defaultSkipTests]
); );
@@ -526,49 +492,26 @@ export function BoardView() {
requirePlanApproval: false, requirePlanApproval: false,
}; };
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData); await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set // Find the newly created feature and start it
const latestFeatures = useAppStore.getState().features; setTimeout(async () => {
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
if (newFeature) { if (newFeature) {
await handleStartImplementation(newFeature); await handleStartImplementation(newFeature);
} else { }
console.error('Could not find newly created feature to start it automatically.'); }, FEATURE_CREATION_SETTLE_DELAY_MS);
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
}, },
[handleAddFeature, handleStartImplementation, defaultSkipTests] [handleAddFeature, handleStartImplementation, defaultSkipTests]
); );
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
console.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress // Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value // Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning); const autoModeRunningRef = useRef(autoMode.isRunning);
@@ -632,37 +575,6 @@ export function BoardView() {
return unsubscribe; return unsubscribe;
}, [currentProject]); }, [currentProject]);
// Listen for backlog plan events (for background generation)
useEffect(() => {
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const unsubscribe = api.backlogPlan.onEvent(
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
if (event.type === 'backlog_plan_complete') {
setIsGeneratingPlan(false);
if (event.result && event.result.changes?.length > 0) {
setPendingBacklogPlan(event.result);
toast.success('Plan ready! Click to review.', {
duration: 10000,
action: {
label: 'Review',
onClick: () => setShowPlanDialog(true),
},
});
} else {
toast.info('No changes generated. Try again with a different prompt.');
}
} else if (event.type === 'backlog_plan_error') {
setIsGeneratingPlan(false);
toast.error(`Plan generation failed: ${event.error}`);
}
}
);
return unsubscribe;
}, []);
useEffect(() => { useEffect(() => {
if (!autoMode.isRunning || !currentProject) { if (!autoMode.isRunning || !currentProject) {
return; return;
@@ -1020,7 +932,6 @@ export function BoardView() {
} }
}} }}
onAddFeature={() => setShowAddDialog(true)} onAddFeature={() => setShowAddDialog(true)}
onOpenPlanDialog={() => setShowPlanDialog(true)}
addFeatureShortcut={{ addFeatureShortcut={{
key: shortcuts.addFeature, key: shortcuts.addFeature,
action: () => setShowAddDialog(true), action: () => setShowAddDialog(true),
@@ -1110,10 +1021,6 @@ export function BoardView() {
onImplement={handleStartImplementation} onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)} onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog} onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext} featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts} shortcuts={shortcuts}
@@ -1121,10 +1028,6 @@ export function BoardView() {
onShowSuggestions={() => setShowSuggestionsDialog(true)} onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount} suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
/> />
) : ( ) : (
<GraphView <GraphView
@@ -1140,12 +1043,6 @@ export function BoardView() {
onStartTask={handleStartImplementation} onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature} onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature} onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/> />
)} )}
</div> </div>
@@ -1180,14 +1077,8 @@ export function BoardView() {
{/* Add Feature Dialog */} {/* Add Feature Dialog */}
<AddFeatureDialog <AddFeatureDialog
open={showAddDialog} open={showAddDialog}
onOpenChange={(open) => { onOpenChange={setShowAddDialog}
setShowAddDialog(open);
if (!open) {
setSpawnParentFeature(null);
}
}}
onAdd={handleAddFeature} onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions} categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions} branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts} branchCardCounts={branchCardCounts}
@@ -1197,8 +1088,6 @@ export function BoardView() {
isMaximized={isMaximized} isMaximized={isMaximized}
showProfilesOnly={showProfilesOnly} showProfilesOnly={showProfilesOnly}
aiProfiles={aiProfiles} aiProfiles={aiProfiles}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/> />
{/* Edit Feature Dialog */} {/* Edit Feature Dialog */}
@@ -1237,22 +1126,6 @@ export function BoardView() {
}} }}
/> />
{/* Pipeline Settings Dialog */}
<PipelineSettingsDialog
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
setPipelineConfig(currentProject.path, config);
}}
/>
{/* Follow-Up Prompt Dialog */} {/* Follow-Up Prompt Dialog */}
<FollowUpDialog <FollowUpDialog
open={showFollowUpDialog} open={showFollowUpDialog}
@@ -1279,18 +1152,6 @@ export function BoardView() {
setIsGenerating={setIsGeneratingSuggestions} setIsGenerating={setIsGeneratingSuggestions}
/> />
{/* Backlog Plan Dialog */}
<BacklogPlanDialog
open={showPlanDialog}
onClose={() => setShowPlanDialog(false)}
projectPath={currentProject.path}
onPlanApplied={loadFeatures}
pendingPlanResult={pendingBacklogPlan}
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
/>
{/* Plan Approval Dialog */} {/* Plan Approval Dialog */}
<PlanApprovalDialog <PlanApprovalDialog
open={pendingPlanApproval !== null} open={pendingPlanApproval !== null}

View File

@@ -1,9 +1,8 @@
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2 } from 'lucide-react'; import { Plus, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -16,7 +15,6 @@ interface BoardHeaderProps {
isAutoModeRunning: boolean; isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void; onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void; onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut; addFeatureShortcut: KeyboardShortcut;
isMounted: boolean; isMounted: boolean;
} }
@@ -29,7 +27,6 @@ export function BoardHeader({
isAutoModeRunning, isAutoModeRunning,
onAutoModeToggle, onAutoModeToggle,
onAddFeature, onAddFeature,
onOpenPlanDialog,
addFeatureShortcut, addFeatureShortcut,
isMounted, isMounted,
}: BoardHeaderProps) { }: BoardHeaderProps) {
@@ -92,16 +89,6 @@ export function BoardHeader({
</div> </div>
)} )}
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
<HotkeyButton <HotkeyButton
size="sm" size="sm"
onClick={onAddFeature} onClick={onAddFeature}

View File

@@ -19,7 +19,6 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Cpu, Cpu,
GitFork,
} from 'lucide-react'; } from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer'; import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
@@ -32,7 +31,6 @@ interface CardHeaderProps {
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onViewOutput?: () => void; onViewOutput?: () => void;
onSpawnTask?: () => void;
} }
export function CardHeaderSection({ export function CardHeaderSection({
@@ -42,7 +40,6 @@ export function CardHeaderSection({
onEdit, onEdit,
onDelete, onDelete,
onViewOutput, onViewOutput,
onSpawnTask,
}: CardHeaderProps) { }: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -95,17 +92,6 @@ export function CardHeaderSection({
<Edit className="w-3 h-3 mr-2" /> <Edit className="w-3 h-3 mr-2" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-running-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */} {/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5"> <div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -120,21 +106,7 @@ export function CardHeaderSection({
{/* Backlog header */} {/* Backlog header */}
{!isCurrentAutoTask && feature.status === 'backlog' && ( {!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2 flex items-center gap-1"> <div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-backlog-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -169,22 +141,6 @@ export function CardHeaderSection({
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`spawn-${
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Spawn Sub-Task"
>
<GitFork className="w-4 h-4" />
</Button>
{onViewOutput && ( {onViewOutput && (
<Button <Button
variant="ghost" variant="ghost"
@@ -273,17 +229,6 @@ export function CardHeaderSection({
View Logs View Logs
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onSpawnTask?.();
}}
data-testid={`spawn-feature-${feature.id}`}
className="text-xs"
>
<GitFork className="w-3 h-3 mr-2" />
Spawn Sub-Task
</DropdownMenuItem>
{/* Model info in dropdown */} {/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5"> <div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -25,7 +25,6 @@ interface KanbanCardProps {
onComplete?: () => void; onComplete?: () => void;
onViewPlan?: () => void; onViewPlan?: () => void;
onApprovePlan?: () => void; onApprovePlan?: () => void;
onSpawnTask?: () => void;
hasContext?: boolean; hasContext?: boolean;
isCurrentAutoTask?: boolean; isCurrentAutoTask?: boolean;
shortcutKey?: string; shortcutKey?: string;
@@ -52,7 +51,6 @@ export const KanbanCard = memo(function KanbanCard({
onComplete, onComplete,
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onSpawnTask,
hasContext, hasContext,
isCurrentAutoTask, isCurrentAutoTask,
shortcutKey, shortcutKey,
@@ -148,7 +146,6 @@ export const KanbanCard = memo(function KanbanCard({
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onViewOutput={onViewOutput} onViewOutput={onViewOutput}
onSpawnTask={onSpawnTask}
/> />
<CardContent className="px-3 pt-0 pb-0"> <CardContent className="px-3 pt-0 pb-0">

View File

@@ -1,28 +1,14 @@
import type { Feature } from '@/store/app-store'; import { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
export type ColumnId = Feature['status']; export type ColumnId = Feature['status'];
export interface Column { export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
isPipelineStep?: boolean;
pipelineStepId?: string;
}
// Base columns (start)
const BASE_COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' }, { id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
{ {
id: 'in_progress', id: 'in_progress',
title: 'In Progress', title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]', colorClass: 'bg-[var(--status-in-progress)]',
}, },
];
// End columns (after pipeline)
const END_COLUMNS: Column[] = [
{ {
id: 'waiting_approval', id: 'waiting_approval',
title: 'Waiting Approval', title: 'Waiting Approval',
@@ -34,58 +20,3 @@ const END_COLUMNS: Column[] = [
colorClass: 'bg-[var(--status-success)]', colorClass: 'bg-[var(--status-success)]',
}, },
]; ];
// Static COLUMNS for backwards compatibility (no pipeline)
export const COLUMNS: Column[] = [...BASE_COLUMNS, ...END_COLUMNS];
/**
* Generate columns including pipeline steps
*/
export function getColumnsWithPipeline(pipelineConfig: PipelineConfig | null): Column[] {
const pipelineSteps = pipelineConfig?.steps || [];
if (pipelineSteps.length === 0) {
return COLUMNS;
}
// Sort steps by order
const sortedSteps = [...pipelineSteps].sort((a, b) => a.order - b.order);
// Convert pipeline steps to columns (filter out invalid steps)
const pipelineColumns: Column[] = sortedSteps
.filter((step) => step && step.id) // Only include valid steps with an id
.map((step) => ({
id: `pipeline_${step.id}` as FeatureStatusWithPipeline,
title: step.name || 'Pipeline Step',
colorClass: step.colorClass || 'bg-[var(--status-in-progress)]',
isPipelineStep: true,
pipelineStepId: step.id,
}));
return [...BASE_COLUMNS, ...pipelineColumns, ...END_COLUMNS];
}
/**
* Get the index where pipeline columns should be inserted
* (after in_progress, before waiting_approval)
*/
export function getPipelineInsertIndex(): number {
return BASE_COLUMNS.length;
}
/**
* Check if a status is a pipeline status
*/
export function isPipelineStatus(status: string): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
export function getStepIdFromStatus(status: string): string | null {
if (!isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}

View File

@@ -23,9 +23,9 @@ import {
MessageSquare, MessageSquare,
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
FlaskConical,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Play,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
@@ -37,7 +37,6 @@ import {
FeatureImage, FeatureImage,
AIProfile, AIProfile,
PlanningMode, PlanningMode,
Feature,
} from '@/store/app-store'; } from '@/store/app-store';
import { import {
ModelSelector, ModelSelector,
@@ -47,7 +46,6 @@ import {
PrioritySelector, PrioritySelector,
BranchSelector, BranchSelector,
PlanningModeSelector, PlanningModeSelector,
AncestorContextSection,
} from '../shared'; } from '../shared';
import { import {
DropdownMenu, DropdownMenu,
@@ -56,34 +54,25 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import {
getAncestors,
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
type FeatureData = {
title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
};
interface AddFeatureDialogProps { interface AddFeatureDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onAdd: (feature: FeatureData) => void; onAdd: (feature: {
onAddAndStart?: (feature: FeatureData) => void; title: string;
category: string;
description: string;
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}) => void;
categorySuggestions: string[]; categorySuggestions: string[];
branchSuggestions: string[]; branchSuggestions: string[];
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
@@ -93,16 +82,12 @@ interface AddFeatureDialogProps {
isMaximized: boolean; isMaximized: boolean;
showProfilesOnly: boolean; showProfilesOnly: boolean;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
// Spawn task mode props
parentFeature?: Feature | null;
allFeatures?: Feature[];
} }
export function AddFeatureDialog({ export function AddFeatureDialog({
open, open,
onOpenChange, onOpenChange,
onAdd, onAdd,
onAddAndStart,
categorySuggestions, categorySuggestions,
branchSuggestions, branchSuggestions,
branchCardCounts, branchCardCounts,
@@ -112,10 +97,7 @@ export function AddFeatureDialog({
isMaximized, isMaximized,
showProfilesOnly, showProfilesOnly,
aiProfiles, aiProfiles,
parentFeature = null,
allFeatures = [],
}: AddFeatureDialogProps) { }: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate(); const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true); const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({ const [newFeature, setNewFeature] = useState({
@@ -143,10 +125,6 @@ export function AddFeatureDialog({
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip'); const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false); const [requirePlanApproval, setRequirePlanApproval] = useState(false);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store // Get enhancement model, planning mode defaults, and worktrees setting from store
const { const {
enhancementModel, enhancementModel,
@@ -175,17 +153,6 @@ export function AddFeatureDialog({
setUseCurrentBranch(true); setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode); setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval); setRequirePlanApproval(defaultRequirePlanApproval);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
setAncestors(ancestorList);
// Only select parent by default - ancestors are optional context
setSelectedAncestorIds(new Set([parentFeature.id]));
} else {
setAncestors([]);
setSelectedAncestorIds(new Set());
}
} }
}, [ }, [
open, open,
@@ -195,20 +162,18 @@ export function AddFeatureDialog({
defaultRequirePlanApproval, defaultRequirePlanApproval,
defaultAIProfileId, defaultAIProfileId,
aiProfiles, aiProfiles,
parentFeature,
allFeatures,
]); ]);
const buildFeatureData = (): FeatureData | null => { const handleAdd = () => {
if (!newFeature.description.trim()) { if (!newFeature.description.trim()) {
setDescriptionError(true); setDescriptionError(true);
return null; return;
} }
// Validate branch selection when "other branch" is selected // Validate branch selection when "other branch" is selected
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) { if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
toast.error('Please select a branch name'); toast.error('Please select a branch name');
return null; return;
} }
const category = newFeature.category || 'Uncategorized'; const category = newFeature.category || 'Uncategorized';
@@ -222,34 +187,10 @@ export function AddFeatureDialog({
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || ''; const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
// Build final description - prepend ancestor context in spawn mode onAdd({
let finalDescription = newFeature.description;
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
// Create parent context as an AncestorContext
const parentContext: AncestorContext = {
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
depth: -1,
};
const allAncestorsWithParent = [parentContext, ...ancestors];
const contextText = formatAncestorContextForPrompt(
allAncestorsWithParent,
selectedAncestorIds
);
if (contextText) {
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
}
}
return {
title: newFeature.title, title: newFeature.title,
category, category,
description: finalDescription, description: newFeature.description,
images: newFeature.images, images: newFeature.images,
imagePaths: newFeature.imagePaths, imagePaths: newFeature.imagePaths,
textFilePaths: newFeature.textFilePaths, textFilePaths: newFeature.textFilePaths,
@@ -260,12 +201,9 @@ export function AddFeatureDialog({
priority: newFeature.priority, priority: newFeature.priority,
planningMode, planningMode,
requirePlanApproval, requirePlanApproval,
// In spawn mode, automatically add parent as dependency });
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
};
};
const resetForm = () => { // Reset form
setNewFeature({ setNewFeature({
title: '', title: '',
category: '', category: '',
@@ -288,20 +226,6 @@ export function AddFeatureDialog({
onOpenChange(false); onOpenChange(false);
}; };
const handleAction = (actionFn?: (data: FeatureData) => void) => {
if (!actionFn) return;
const featureData = buildFeatureData();
if (!featureData) return;
actionFn(featureData);
resetForm();
};
const handleAdd = () => handleAction(onAdd);
const handleAddAndStart = () => handleAction(onAddAndStart);
const handleDialogClose = (open: boolean) => { const handleDialogClose = (open: boolean) => {
onOpenChange(open); onOpenChange(open);
if (!open) { if (!open) {
@@ -375,12 +299,8 @@ export function AddFeatureDialog({
}} }}
> >
<DialogHeader> <DialogHeader>
<DialogTitle>{isSpawnMode ? 'Spawn Sub-Task' : 'Add New Feature'}</DialogTitle> <DialogTitle>Add New Feature</DialogTitle>
<DialogDescription> <DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
{isSpawnMode
? `Create a sub-task that depends on "${parentFeature?.title || parentFeature?.description.slice(0, 50)}..."`
: 'Create a new feature card for the Kanban board.'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col"> <Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4"> <TabsList className="w-full grid grid-cols-3 mb-4">
@@ -400,22 +320,6 @@ export function AddFeatureDialog({
{/* Prompt Tab */} {/* Prompt Tab */}
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default"> <TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
{/* Ancestor Context Section - only in spawn mode */}
{isSpawnMode && parentFeature && (
<AncestorContextSection
parentFeature={{
id: parentFeature.id,
title: parentFeature.title,
description: parentFeature.description,
spec: parentFeature.spec,
summary: parentFeature.summary,
}}
ancestors={ancestors}
selectedAncestorIds={selectedAncestorIds}
onSelectionChange={setSelectedAncestorIds}
/>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<DescriptionImageDropZone <DescriptionImageDropZone
@@ -601,17 +505,6 @@ export function AddFeatureDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
{onAddAndStart && (
<Button
onClick={handleAddAndStart}
variant="secondary"
data-testid="confirm-add-and-start-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
>
<Play className="w-4 h-4 mr-2" />
Make
</Button>
)}
<HotkeyButton <HotkeyButton
onClick={handleAdd} onClick={handleAdd}
hotkey={{ key: 'Enter', cmdCtrl: true }} hotkey={{ key: 'Enter', cmdCtrl: true }}
@@ -619,7 +512,7 @@ export function AddFeatureDialog({
data-testid="confirm-add-feature" data-testid="confirm-add-feature"
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()} disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
> >
{isSpawnMode ? 'Spawn Task' : 'Add Feature'} Add Feature
</HotkeyButton> </HotkeyButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -45,7 +45,7 @@ export function ArchiveAllVerifiedDialog({
</Button> </Button>
<Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified"> <Button variant="default" onClick={onConfirm} data-testid="confirm-archive-all-verified">
<Archive className="w-4 h-4 mr-2" /> <Archive className="w-4 h-4 mr-2" />
Complete All Archive All
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -1,418 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Loader2,
Wand2,
Check,
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { BacklogPlanResult, BacklogChange } from '@automaker/types';
interface BacklogPlanDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
onPlanApplied?: () => void;
// Props for background generation
pendingPlanResult: BacklogPlanResult | null;
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
}
type DialogMode = 'input' | 'review' | 'applying';
export function BacklogPlanDialog({
open,
onClose,
projectPath,
onPlanApplied,
pendingPlanResult,
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
const [selectedChanges, setSelectedChanges] = useState<Set<number>>(new Set());
// Set mode based on whether we have a pending result
useEffect(() => {
if (open) {
if (pendingPlanResult) {
setMode('review');
// Select all changes by default
setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i)));
setExpandedChanges(new Set());
} else {
setMode('input');
}
}
}, [open, pendingPlanResult]);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) {
toast.error('Please enter a prompt describing the changes you want');
return;
}
const api = getElectronAPI();
if (!api?.backlogPlan) {
toast.error('API not available');
return;
}
// Start generation in background
setIsGeneratingPlan(true);
const result = await api.backlogPlan.generate(projectPath, prompt);
if (!result.success) {
setIsGeneratingPlan(false);
toast.error(result.error || 'Failed to start plan generation');
return;
}
// Show toast and close dialog - generation runs in background
toast.info('Generating plan... This will be ready soon!', {
duration: 3000,
});
setPrompt('');
onClose();
}, [projectPath, prompt, setIsGeneratingPlan, onClose]);
const handleApply = useCallback(async () => {
if (!pendingPlanResult) return;
// Filter to only selected changes
const selectedChangesList = pendingPlanResult.changes.filter((_, index) =>
selectedChanges.has(index)
);
if (selectedChangesList.length === 0) {
toast.error('Please select at least one change to apply');
return;
}
const api = getElectronAPI();
if (!api?.backlogPlan) {
toast.error('API not available');
return;
}
setMode('applying');
// Create a filtered plan result with only selected changes
const filteredPlanResult: BacklogPlanResult = {
...pendingPlanResult,
changes: selectedChangesList,
// Filter dependency updates to only include those for selected features
dependencyUpdates:
pendingPlanResult.dependencyUpdates?.filter((update) => {
const isDeleting = selectedChangesList.some(
(c) => c.type === 'delete' && c.featureId === update.featureId
);
return !isDeleting;
}) || [],
};
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
onPlanApplied?.();
onClose();
} else {
toast.error(result.error || 'Failed to apply plan');
setMode('review');
}
}, [
projectPath,
pendingPlanResult,
selectedChanges,
setPendingPlanResult,
onPlanApplied,
onClose,
]);
const handleDiscard = useCallback(() => {
setPendingPlanResult(null);
setMode('input');
}, [setPendingPlanResult]);
const toggleChangeExpanded = (index: number) => {
setExpandedChanges((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
const toggleChangeSelected = (index: number) => {
setSelectedChanges((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
const toggleAllChanges = () => {
if (!pendingPlanResult) return;
if (selectedChanges.size === pendingPlanResult.changes.length) {
setSelectedChanges(new Set());
} else {
setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i)));
}
};
const getChangeIcon = (type: BacklogChange['type']) => {
switch (type) {
case 'add':
return <Plus className="w-4 h-4 text-green-500" />;
case 'update':
return <Pencil className="w-4 h-4 text-yellow-500" />;
case 'delete':
return <Trash2 className="w-4 h-4 text-red-500" />;
}
};
const getChangeLabel = (change: BacklogChange) => {
switch (change.type) {
case 'add':
return change.feature?.title || 'New Feature';
case 'update':
return `Update: ${change.featureId}`;
case 'delete':
return `Delete: ${change.featureId}`;
}
};
const renderContent = () => {
switch (mode) {
case 'input':
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Describe the changes you want to make to your backlog. The AI will analyze your
current features and propose additions, updates, or deletions.
</div>
<Textarea
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[150px] resize-none"
autoFocus
/>
<div className="text-xs text-muted-foreground">
The AI will automatically handle dependency graph updates when adding or removing
features.
</div>
{isGeneratingPlan && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
the background...
</div>
)}
</div>
);
case 'review':
if (!pendingPlanResult) return null;
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
const updates = pendingPlanResult.changes.filter((c) => c.type === 'update');
const deletions = pendingPlanResult.changes.filter((c) => c.type === 'delete');
const allSelected = selectedChanges.size === pendingPlanResult.changes.length;
const someSelected = selectedChanges.size > 0 && !allSelected;
return (
<div className="space-y-4">
{/* Summary */}
<div className="rounded-lg border bg-muted/30 p-4">
<h4 className="font-medium mb-2">Summary</h4>
<p className="text-sm text-muted-foreground">{pendingPlanResult.summary}</p>
</div>
{/* Stats */}
<div className="flex gap-4 text-sm">
{additions.length > 0 && (
<span className="flex items-center gap-1 text-green-600">
<Plus className="w-4 h-4" /> {additions.length} additions
</span>
)}
{updates.length > 0 && (
<span className="flex items-center gap-1 text-yellow-600">
<Pencil className="w-4 h-4" /> {updates.length} updates
</span>
)}
{deletions.length > 0 && (
<span className="flex items-center gap-1 text-red-600">
<Trash2 className="w-4 h-4" /> {deletions.length} deletions
</span>
)}
</div>
{/* Select all */}
<div className="flex items-center gap-2 pb-2 border-b">
<Checkbox
id="select-all"
checked={allSelected}
// @ts-expect-error - indeterminate is valid but not in types
indeterminate={someSelected}
onCheckedChange={toggleAllChanges}
/>
<label htmlFor="select-all" className="text-sm font-medium cursor-pointer">
{allSelected ? 'Deselect all' : 'Select all'} ({selectedChanges.size}/
{pendingPlanResult.changes.length})
</label>
</div>
{/* Changes list */}
<div className="max-h-[300px] overflow-y-auto space-y-2">
{pendingPlanResult.changes.map((change, index) => (
<div
key={index}
className={cn(
'rounded-lg border p-3',
change.type === 'add' && 'border-green-500/30 bg-green-500/5',
change.type === 'update' && 'border-yellow-500/30 bg-yellow-500/5',
change.type === 'delete' && 'border-red-500/30 bg-red-500/5',
!selectedChanges.has(index) && 'opacity-50'
)}
>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedChanges.has(index)}
onCheckedChange={() => toggleChangeSelected(index)}
/>
<button
className="flex-1 flex items-center gap-2 text-left"
onClick={() => toggleChangeExpanded(index)}
>
{expandedChanges.has(index) ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
{getChangeIcon(change.type)}
<span className="font-medium text-sm">{getChangeLabel(change)}</span>
</button>
</div>
{expandedChanges.has(index) && (
<div className="mt-3 pl-10 space-y-2 text-sm">
<p className="text-muted-foreground">{change.reason}</p>
{change.feature && (
<div className="rounded bg-background/50 p-2 text-xs font-mono">
{change.feature.description && (
<p className="text-foreground">{change.feature.description}</p>
)}
{change.feature.dependencies &&
change.feature.dependencies.length > 0 && (
<p className="text-muted-foreground mt-1">
Dependencies: {change.feature.dependencies.join(', ')}
</p>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
);
case 'applying':
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Applying changes...</p>
</div>
);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="w-5 h-5 text-primary" />
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
</DialogTitle>
<DialogDescription>
{mode === 'review'
? 'Select which changes to apply to your backlog'
: 'Use AI to add, update, or remove features from your backlog'}
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderContent()}</div>
<DialogFooter>
{mode === 'input' && (
<>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
{isGeneratingPlan ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Wand2 className="w-4 h-4 mr-2" />
Generate Plan
</>
)}
</Button>
</>
)}
{mode === 'review' && (
<>
<Button variant="outline" onClick={handleDiscard}>
Discard
</Button>
<Button variant="outline" onClick={onClose}>
Review Later
</Button>
<Button onClick={handleApply} disabled={selectedChanges.size === 0}>
<Check className="w-4 h-4 mr-2" />
Apply {selectedChanges.size} Change{selectedChanges.size !== 1 ? 's' : ''}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
)} )}
> >
{(dep.status || 'backlog').replace(/_/g, ' ')} {dep.status.replace(/_/g, ' ')}
</span> </span>
</div> </div>
</div> </div>
@@ -177,7 +177,7 @@ export function DependencyTreeDialog({
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
)} )}
> >
{(dependent.status || 'backlog').replace(/_/g, ' ')} {dependent.status.replace(/_/g, ' ')}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
export { AddFeatureDialog } from './add-feature-dialog'; export { AddFeatureDialog } from './add-feature-dialog';
export { AgentOutputModal } from './agent-output-modal'; export { AgentOutputModal } from './agent-output-modal';
export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal'; export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';

View File

@@ -1,736 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X, FileText } from 'lucide-react';
import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils';
// Color options for pipeline columns
const COLOR_OPTIONS = [
{ value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' },
{ value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' },
{ value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' },
{ value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' },
{ value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' },
{ value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' },
{ value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' },
{ value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' },
{ value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' },
];
// Pre-built step templates with well-designed prompts
const STEP_TEMPLATES = [
{
id: 'code-review',
name: 'Code Review',
colorClass: 'bg-blue-500/20',
instructions: `## Code Review
Please perform a thorough code review of the changes made in this feature. Focus on:
### Code Quality
- **Readability**: Is the code easy to understand? Are variable/function names descriptive?
- **Maintainability**: Will this code be easy to modify in the future?
- **DRY Principle**: Is there any duplicated code that should be abstracted?
- **Single Responsibility**: Do functions and classes have a single, clear purpose?
### Best Practices
- Follow established patterns and conventions used in the codebase
- Ensure proper error handling is in place
- Check for appropriate logging where needed
- Verify that magic numbers/strings are replaced with named constants
### Performance
- Identify any potential performance bottlenecks
- Check for unnecessary re-renders (React) or redundant computations
- Ensure efficient data structures are used
### Testing
- Verify that new code has appropriate test coverage
- Check that edge cases are handled
### Action Required
After reviewing, make any necessary improvements directly. If you find issues:
1. Fix them immediately if they are straightforward
2. For complex issues, document them clearly with suggested solutions
Provide a brief summary of changes made or issues found.`,
},
{
id: 'security-review',
name: 'Security Review',
colorClass: 'bg-red-500/20',
instructions: `## Security Review
Perform a comprehensive security audit of the changes made in this feature. Check for vulnerabilities in the following areas:
### Input Validation & Sanitization
- Verify all user inputs are properly validated and sanitized
- Check for SQL injection vulnerabilities
- Check for XSS (Cross-Site Scripting) vulnerabilities
- Ensure proper encoding of output data
### Authentication & Authorization
- Verify authentication checks are in place where needed
- Ensure authorization logic correctly restricts access
- Check for privilege escalation vulnerabilities
- Verify session management is secure
### Data Protection
- Ensure sensitive data is not logged or exposed
- Check that secrets/credentials are not hardcoded
- Verify proper encryption is used for sensitive data
- Check for secure transmission of data (HTTPS, etc.)
### Common Vulnerabilities (OWASP Top 10)
- Injection flaws
- Broken authentication
- Sensitive data exposure
- XML External Entities (XXE)
- Broken access control
- Security misconfiguration
- Cross-Site Scripting (XSS)
- Insecure deserialization
- Using components with known vulnerabilities
- Insufficient logging & monitoring
### Action Required
1. Fix any security vulnerabilities immediately
2. For complex security issues, document them with severity levels
3. Add security-related comments where appropriate
Provide a security assessment summary with any issues found and fixes applied.`,
},
{
id: 'testing',
name: 'Testing',
colorClass: 'bg-green-500/20',
instructions: `## Testing Step
Please ensure comprehensive test coverage for the changes made in this feature.
### Unit Tests
- Write unit tests for all new functions and methods
- Ensure edge cases are covered
- Test error handling paths
- Aim for high code coverage on new code
### Integration Tests
- Test interactions between components/modules
- Verify API endpoints work correctly
- Test database operations if applicable
### Test Quality
- Tests should be readable and well-documented
- Each test should have a clear purpose
- Use descriptive test names that explain the scenario
- Follow the Arrange-Act-Assert pattern
### Run Tests
After writing tests, run the full test suite and ensure:
1. All new tests pass
2. No existing tests are broken
3. Test coverage meets project standards
Provide a summary of tests added and any issues found during testing.`,
},
{
id: 'documentation',
name: 'Documentation',
colorClass: 'bg-amber-500/20',
instructions: `## Documentation Step
Please ensure all changes are properly documented.
### Code Documentation
- Add/update JSDoc or docstrings for new functions and classes
- Document complex algorithms or business logic
- Add inline comments for non-obvious code
### API Documentation
- Document any new or modified API endpoints
- Include request/response examples
- Document error responses
### README Updates
- Update README if new setup steps are required
- Document any new environment variables
- Update architecture diagrams if applicable
### Changelog
- Document notable changes for the changelog
- Include breaking changes if any
Provide a summary of documentation added or updated.`,
},
{
id: 'optimization',
name: 'Performance Optimization',
colorClass: 'bg-cyan-500/20',
instructions: `## Performance Optimization Step
Review and optimize the performance of the changes made in this feature.
### Code Performance
- Identify and optimize slow algorithms (O(n²) → O(n log n), etc.)
- Remove unnecessary computations or redundant operations
- Optimize loops and iterations
- Use appropriate data structures
### Memory Usage
- Check for memory leaks
- Optimize memory-intensive operations
- Ensure proper cleanup of resources
### Database/API
- Optimize database queries (add indexes, reduce N+1 queries)
- Implement caching where appropriate
- Batch API calls when possible
### Frontend (if applicable)
- Minimize bundle size
- Optimize render performance
- Implement lazy loading where appropriate
- Use memoization for expensive computations
### Action Required
1. Profile the code to identify bottlenecks
2. Apply optimizations
3. Measure improvements
Provide a summary of optimizations applied and performance improvements achieved.`,
},
];
// Helper to get template color class
const getTemplateColorClass = (templateId: string): string => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
return template?.colorClass || COLOR_OPTIONS[0].value;
};
interface PipelineSettingsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
pipelineConfig: PipelineConfig | null;
onSave: (config: PipelineConfig) => Promise<void>;
}
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({
open,
onClose,
projectPath,
pipelineConfig,
onSave,
}: PipelineSettingsDialogProps) {
// Filter and validate steps to ensure all required properties exist
const validateSteps = (steps: PipelineStep[] | undefined): PipelineStep[] => {
if (!Array.isArray(steps)) return [];
return steps.filter(
(step): step is PipelineStep =>
step != null &&
typeof step.id === 'string' &&
typeof step.name === 'string' &&
typeof step.instructions === 'string'
);
};
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync steps when dialog opens or pipelineConfig changes
useEffect(() => {
if (open) {
setSteps(validateSteps(pipelineConfig?.steps));
}
}, [open, pipelineConfig]);
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => {
setEditingStep({
name: '',
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
};
const handleEditStep = (step: PipelineStep) => {
setEditingStep({
id: step.id,
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
};
const handleDeleteStep = (stepId: string) => {
const newSteps = steps.filter((s) => s.id !== stepId);
// Reorder remaining steps
newSteps.forEach((s, index) => {
s.order = index;
});
setSteps(newSteps);
};
const handleMoveStep = (stepId: string, direction: 'up' | 'down') => {
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
if (
(direction === 'up' && stepIndex === 0) ||
(direction === 'down' && stepIndex === sortedSteps.length - 1)
) {
return;
}
const newSteps = [...sortedSteps];
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
// Swap orders
const temp = newSteps[stepIndex].order;
newSteps[stepIndex].order = newSteps[targetIndex].order;
newSteps[targetIndex].order = temp;
setSteps(newSteps);
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
toast.success('Instructions loaded from file');
} catch (error) {
toast.error('Failed to load file');
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing step
setSteps((prev) =>
prev.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
)
);
} else {
// Add new step
const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: steps.length,
createdAt: now,
updatedAt: now,
};
setSteps((prev) => [...prev, newStep]);
}
setEditingStep(null);
};
const handleSaveConfig = async () => {
setIsSubmitting(true);
try {
// If the user is currently editing a step and clicks "Save Configuration",
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = {
version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
};
await onSave(config);
toast.success('Pipeline configuration saved');
onClose();
} catch (error) {
toast.error('Failed to save pipeline configuration');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div
className={cn(
'w-3 h-8 rounded',
(step.colorClass || 'bg-blue-500/20').replace('/20', '')
)}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" />
Add Pipeline Step
</Button>
)}
{/* Edit/Add Step Form */}
{editingStep && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/20">
<div className="flex items-center justify-between">
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingStep(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */}
{!editingStep.id && (
<div className="space-y-2">
<Label>Start from Template</Label>
<Select
onValueChange={(templateId) => {
const template = STEP_TEMPLATES.find((t) => t.id === templateId);
if (template) {
setEditingStep((prev) =>
prev
? {
...prev,
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -98,7 +98,6 @@ export function useBoardActions({
priority: number; priority: number;
planningMode: PlanningMode; planningMode: PlanningMode;
requirePlanApproval: boolean; requirePlanApproval: boolean;
dependencies?: string[];
}) => { }) => {
// Empty string means "unassigned" (show only on primary worktree) - convert to undefined // Empty string means "unassigned" (show only on primary worktree) - convert to undefined
// Non-empty string is the actual branch name (for non-primary worktrees) // Non-empty string is the actual branch name (for non-primary worktrees)
@@ -151,7 +150,6 @@ export function useBoardActions({
titleGenerating: needsTitleGeneration, titleGenerating: needsTitleGeneration,
status: 'backlog' as const, status: 'backlog' as const,
branchName: finalBranchName, branchName: finalBranchName,
dependencies: featureData.dependencies || [],
}; };
const createdFeature = addFeature(newFeatureData); const createdFeature = addFeature(newFeatureData);
// Must await to ensure feature exists on server before user can drag it // Must await to ensure feature exists on server before user can drag it

View File

@@ -23,8 +23,7 @@ export function useBoardColumnFeatures({
}: UseBoardColumnFeaturesProps) { }: UseBoardColumnFeaturesProps) {
// Memoize column features to prevent unnecessary re-renders // Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => { const columnFeaturesMap = useMemo(() => {
// Use a more flexible type to support dynamic pipeline statuses const map: Record<ColumnId, Feature[]> = {
const map: Record<string, Feature[]> = {
backlog: [], backlog: [],
in_progress: [], in_progress: [],
waiting_approval: [], waiting_approval: [],
@@ -77,56 +76,31 @@ export function useBoardColumnFeatures({
matchesWorktree = featureBranch === effectiveBranch; matchesWorktree = featureBranch === effectiveBranch;
} }
// Use the feature's status (fallback to backlog for unknown statuses)
const status = f.status || 'backlog';
// IMPORTANT:
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
if (isRunning) { if (isRunning) {
if (!matchesWorktree) return; // Only show running tasks if they match the current worktree
if (matchesWorktree) {
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
map[status].push(f);
return;
}
// If it's running and has a known non-backlog status, keep it in that status.
// Otherwise, fallback to in_progress as the "active work" column.
if (status !== 'backlog' && map[status]) {
map[status].push(f);
} else {
map.in_progress.push(f); map.in_progress.push(f);
} }
return;
}
// Not running: place by status (and worktree filter)
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else if (status.startsWith('pipeline_')) {
// Handle pipeline statuses - initialize array if needed
if (matchesWorktree) {
if (!map[status]) {
map[status] = [];
}
map[status].push(f);
}
} else { } else {
// Unknown status, default to backlog // Otherwise, use the feature's status (fallback to backlog for unknown statuses)
if (matchesWorktree) { const status = f.status as ColumnId;
map.backlog.push(f);
// Filter all items by worktree, including backlog
// This ensures backlog items with a branch assigned only show in that branch
if (status === 'backlog') {
if (matchesWorktree) {
map.backlog.push(f);
}
} else if (map[status]) {
// Only show if matches current worktree or has no worktree assigned
if (matchesWorktree) {
map[status].push(f);
}
} else {
// Unknown status, default to backlog
if (matchesWorktree) {
map.backlog.push(f);
}
} }
} }
}); });
@@ -173,7 +147,7 @@ export function useBoardColumnFeatures({
const getColumnFeatures = useCallback( const getColumnFeatures = useCallback(
(columnId: ColumnId) => { (columnId: ColumnId) => {
return columnFeaturesMap[columnId] || []; return columnFeaturesMap[columnId];
}, },
[columnFeaturesMap] [columnFeaturesMap]
); );

View File

@@ -203,11 +203,6 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
// This ensures the feature card shows the "Approve Plan" button // This ensures the feature card shows the "Approve Plan" button
console.log('[Board] Plan approval required, reloading features...'); console.log('[Board] Plan approval required, reloading features...');
loadFeatures(); loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
console.log('[Board] Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') { } else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval) // Reload features when an error occurs (feature moved to waiting_approval)
console.log('[Board] Feature error, reloading features...', event.error); console.log('[Board] Feature error, reloading features...', event.error);

Some files were not shown because too many files have changed in this diff Show More