mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Compare commits
82 Commits
security-s
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e855cc5 | ||
|
|
3c719f05a1 | ||
|
|
9cba2e509a | ||
|
|
c61eaff525 | ||
|
|
ef0a96182a | ||
|
|
a680f3a9c1 | ||
|
|
ea6a39c6ab | ||
|
|
f0c2860dec | ||
|
|
85dfabec0a | ||
|
|
15dca79fb7 | ||
|
|
e9b366fa18 | ||
|
|
145dcf4b97 | ||
|
|
4a708aa305 | ||
|
|
3a1781eb39 | ||
|
|
5f328a4c13 | ||
|
|
f7a0365bee | ||
|
|
4eae231166 | ||
|
|
ba4540b13e | ||
|
|
01911287f2 | ||
|
|
7b7de2b601 | ||
|
|
b65fccbcf7 | ||
|
|
71c17e1fbb | ||
|
|
296ef20ef7 | ||
|
|
23d6756f03 | ||
|
|
01e6b7fa52 | ||
|
|
348a4d95e9 | ||
|
|
94e166636b | ||
|
|
920dcd105f | ||
|
|
b60e8f0392 | ||
|
|
35d2d8cc01 | ||
|
|
d4b2a3eb27 | ||
|
|
2caa63ae21 | ||
|
|
4c16e5e09c | ||
|
|
ad983c6422 | ||
|
|
0fe6a12d20 | ||
|
|
ce78165b59 | ||
|
|
17c1c733b7 | ||
|
|
3bb9d27dc6 | ||
|
|
04a5ae48e2 | ||
|
|
6d3314f980 | ||
|
|
35541f810d | ||
|
|
3d361028b3 | ||
|
|
7f4b60b8c0 | ||
|
|
1c59eabf5f | ||
|
|
f95282069d | ||
|
|
a3fcf5bda1 | ||
|
|
a7de6406ed | ||
|
|
fd51abb3ce | ||
|
|
cd30306afe | ||
|
|
bed8038d16 | ||
|
|
862a33982d | ||
|
|
90ebb52536 | ||
|
|
072ad72f14 | ||
|
|
387bb15a3d | ||
|
|
077dd31b4f | ||
|
|
99a19cb2a2 | ||
|
|
407cf633e0 | ||
|
|
b0ce01d008 | ||
|
|
3154121840 | ||
|
|
8f2d134d03 | ||
|
|
07bcb6b767 | ||
|
|
8a0226512d | ||
|
|
5418d04529 | ||
|
|
3325b91de9 | ||
|
|
aad5dfc745 | ||
|
|
60d4b5c877 | ||
|
|
9dee9fb366 | ||
|
|
ccc7c6c21d | ||
|
|
896e183e41 | ||
|
|
7c0d70ab3c | ||
|
|
91eeda3a73 | ||
|
|
e4235cbd4b | ||
|
|
fc7f342617 | ||
|
|
6aa9e5fbc9 | ||
|
|
34c0d39e39 | ||
|
|
686a24d3c6 | ||
|
|
3307ff8100 | ||
|
|
502043f6de | ||
|
|
76b7cfec9e | ||
|
|
8d80c73faa | ||
|
|
e07fba13d8 | ||
|
|
dbc21c8f73 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -80,3 +80,4 @@ blob-report/
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
.claude/
|
||||||
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# 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
|
||||||
414
README.md
414
README.md
@@ -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 Code](#powered-by-claude-code)
|
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
|
||||||
- [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,22 +28,36 @@
|
|||||||
- [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)
|
||||||
- [Authentication Options](#authentication-options)
|
- [Environment Configuration](#environment-configuration)
|
||||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
- [Authentication Setup](#authentication-setup)
|
||||||
- [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 Code automatically implement them.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -59,9 +73,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 Code
|
### Powered by Claude Agent SDK
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Why This Matters
|
### Why This Matters
|
||||||
|
|
||||||
@@ -95,8 +109,7 @@ 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:**
|
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||||
https://discord.gg/jjem7aEDKU
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,28 +117,49 @@ https://discord.gg/jjem7aEDKU
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- **Node.js 18+** (tested with Node.js 22)
|
||||||
- npm
|
- **npm** (comes with Node.js)
|
||||||
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
|
- **Authentication** (choose one):
|
||||||
|
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
|
||||||
|
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repo
|
# 1. Clone the repository
|
||||||
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 local shared packages
|
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||||
npm run build:packages
|
npm run build:packages
|
||||||
|
|
||||||
# 4. Run Automaker (pick your mode)
|
# 4. Set up authentication (skip if using Claude Code CLI)
|
||||||
|
# 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
|
||||||
# Then choose your run mode when prompted, or use specific commands below
|
# Choose between:
|
||||||
|
# 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
|
||||||
@@ -163,31 +197,65 @@ npm run dev:web
|
|||||||
|
|
||||||
### Building for Production
|
### Building for Production
|
||||||
|
|
||||||
|
#### Web Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build Next.js app
|
# Build for web deployment (uses Vite)
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Build Electron app for distribution
|
# Run production build
|
||||||
npm run build:electron
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Production Build
|
#### Desktop Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start production Next.js server
|
# Build for current platform (macOS/Windows/Linux)
|
||||||
npm run start
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run with Docker Compose (recommended for security)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Access at http://localhost:3007
|
||||||
|
# API at http://localhost:3008
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
```bash
|
#### End-to-End Tests (Playwright)
|
||||||
# Run tests headless
|
|
||||||
npm run test
|
|
||||||
|
|
||||||
# Run tests with browser visible
|
```bash
|
||||||
npm run test:headed
|
npm run test # Headless E2E tests
|
||||||
|
npm run test:headed # Browser visible E2E tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
@@ -195,59 +263,283 @@ npm run test:headed
|
|||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication Options
|
### Environment Configuration
|
||||||
|
|
||||||
Automaker supports multiple authentication methods (in order of priority):
|
#### Authentication (if not using Claude Code CLI)
|
||||||
|
|
||||||
| Method | Environment Variable | Description |
|
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
|
||||||
| ---------------- | -------------------- | ------------------------------- |
|
|
||||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
|
||||||
| API Key (stored) | — | Anthropic API key stored in app |
|
|
||||||
|
|
||||||
### Persistent Setup (Optional)
|
#### Optional - Server
|
||||||
|
|
||||||
|
- `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="YOUR_API_KEY_HERE"
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart your terminal or run `source ~/.bashrc`.
|
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
|
||||||
|
|
||||||
|
##### 2b. .env File
|
||||||
|
|
||||||
|
Create a `.env` file in the project root (gitignored):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
PORT=3008
|
||||||
|
DATA_DIR=./data
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 2c. In-App Storage
|
||||||
|
|
||||||
|
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
|
||||||
|
|
||||||
## Features
|
## 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"
|
||||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
|
||||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
|
||||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
|
||||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
|
||||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
### AI & Planning
|
||||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
|
||||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
|
||||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
|
||||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
|
||||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins
|
||||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
|
||||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
|
||||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
### Project Management
|
||||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
|
||||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
|
||||||
|
- 💡 **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
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org) - React framework
|
### Frontend
|
||||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
- **React 19** - UI framework
|
||||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
- **Vite 7** - Build tool and development server
|
||||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
- **Electron 39** - Desktop application framework
|
||||||
|
- **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
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
### Documentation
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
|
||||||
|
- [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
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ 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();
|
||||||
@@ -100,9 +105,13 @@ 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 || '*',
|
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -112,11 +121,13 @@ app.use(express.json({ limit: '50mb' }));
|
|||||||
const events: EventEmitter = createEventEmitter();
|
const events: EventEmitter = createEventEmitter();
|
||||||
|
|
||||||
// Create services
|
// Create services
|
||||||
const agentService = new AgentService(DATA_DIR, events);
|
// Note: settingsService is created first so it can be injected into other services
|
||||||
const featureLoader = new FeatureLoader();
|
|
||||||
const autoModeService = new AutoModeService(events);
|
|
||||||
const settingsService = new SettingsService(DATA_DIR);
|
const settingsService = new SettingsService(DATA_DIR);
|
||||||
|
const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||||
|
const featureLoader = new FeatureLoader();
|
||||||
|
const autoModeService = new AutoModeService(events, settingsService);
|
||||||
const claudeUsageService = new ClaudeUsageService();
|
const claudeUsageService = new ClaudeUsageService();
|
||||||
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -148,17 +159,20 @@ 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));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||||
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));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes());
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
|
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);
|
||||||
@@ -187,12 +201,31 @@ 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');
|
console.log('[WebSocket] Client connected, ready state:', ws.readyState);
|
||||||
|
|
||||||
// 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) {
|
||||||
ws.send(JSON.stringify({ type, payload }));
|
const message = 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +235,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,30 +2,9 @@
|
|||||||
* 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;
|
||||||
@@ -35,7 +14,6 @@ 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
|
||||||
@@ -44,22 +22,6 @@ 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;
|
||||||
|
|
||||||
@@ -71,10 +33,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!secureCompare(providedKey, API_KEY)) {
|
if (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.',
|
||||||
@@ -82,9 +41,6 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful authentication - reset rate limiter for this IP
|
|
||||||
apiKeyRateLimiter.reset(clientIp);
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 } from '@automaker/types';
|
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +136,106 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -160,8 +260,31 @@ 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
|
||||||
*
|
*
|
||||||
@@ -169,11 +292,15 @@ export interface CreateSdkOptionsConfig {
|
|||||||
* - 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
|
||||||
@@ -184,7 +311,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],
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||||
};
|
};
|
||||||
@@ -197,11 +324,15 @@ 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
|
||||||
@@ -210,7 +341,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],
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -222,18 +353,22 @@ 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],
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||||
};
|
};
|
||||||
@@ -246,7 +381,8 @@ 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 enabled for bash safety
|
* - Sandbox mode controlled by enableSandboxMode setting
|
||||||
|
* - 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
|
||||||
@@ -255,18 +391,30 @@ 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,
|
||||||
allowedTools: [...TOOL_PRESETS.chat],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
|
...(config.enableSandboxMode && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoAllowBashIfSandboxed: true,
|
autoAllowBashIfSandboxed: true,
|
||||||
},
|
},
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
}),
|
||||||
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,24 +425,37 @@ 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 enabled for bash safety
|
* - Sandbox mode controlled by enableSandboxMode setting
|
||||||
|
* - 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,
|
||||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
|
...(config.enableSandboxMode && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoAllowBashIfSandboxed: true,
|
autoAllowBashIfSandboxed: true,
|
||||||
},
|
},
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
}),
|
||||||
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +463,7 @@ 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 & {
|
||||||
@@ -313,14 +475,30 @@ 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,
|
||||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||||
...(config.sandbox && { sandbox: config.sandbox }),
|
...(config.sandbox && { sandbox: config.sandbox }),
|
||||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ export const {
|
|||||||
lstat,
|
lstat,
|
||||||
joinPath,
|
joinPath,
|
||||||
resolvePath,
|
resolvePath,
|
||||||
|
// Throttling configuration and monitoring
|
||||||
|
configureThrottling,
|
||||||
|
getThrottlingConfig,
|
||||||
|
getPendingOperations,
|
||||||
|
getActiveOperations,
|
||||||
} = secureFs;
|
} = secureFs;
|
||||||
|
|||||||
257
apps/server/src/lib/settings-helpers.ts
Normal file
257
apps/server/src/lib/settings-helpers.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,29 +7,6 @@
|
|||||||
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')
|
||||||
@@ -50,8 +27,7 @@ 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 !== undefined && value !== null) {
|
if (value) {
|
||||||
assertValidPathString(value, actualName);
|
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -61,30 +37,17 @@ 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) {
|
||||||
// Skip if not provided or empty
|
for (const value of values) {
|
||||||
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);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle regular parameters
|
// Handle regular parameters
|
||||||
const value = req.body[paramName];
|
const value = req.body[paramName];
|
||||||
if (value !== undefined && value !== null) {
|
if (value) {
|
||||||
assertValidPathString(value, paramName);
|
|
||||||
validatePath(value);
|
validatePath(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,14 +62,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,25 +36,44 @@ 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,
|
||||||
allowedTools: toolsToUse,
|
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||||
permissionMode: 'acceptEdits',
|
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||||
sandbox: {
|
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||||
enabled: true,
|
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||||
autoAllowBashIfSandboxed: true,
|
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||||
},
|
// 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
|
||||||
@@ -88,7 +107,8 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
yield msg as ProviderMessage;
|
yield msg as ProviderMessage;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
|
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
|
||||||
|
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
* Configuration for a provider instance
|
export type {
|
||||||
*/
|
ProviderConfig,
|
||||||
export interface ProviderConfig {
|
ConversationMessage,
|
||||||
apiKey?: string;
|
ExecuteOptions,
|
||||||
cliPath?: string;
|
McpServerConfig,
|
||||||
env?: Record<string, string>;
|
McpStdioServerConfig,
|
||||||
}
|
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)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ 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();
|
||||||
@@ -27,5 +31,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
34
apps/server/src/routes/agent/routes/queue-add.ts
Normal file
34
apps/server/src/routes/agent/routes/queue-add.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
29
apps/server/src/routes/agent/routes/queue-clear.ts
Normal file
29
apps/server/src/routes/agent/routes/queue-clear.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
29
apps/server/src/routes/agent/routes/queue-list.ts
Normal file
29
apps/server/src/routes/agent/routes/queue-list.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
32
apps/server/src/routes/agent/routes/queue-remove.ts
Normal file
32
apps/server/src/routes/agent/routes/queue-remove.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,16 @@ 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',
|
||||||
@@ -27,6 +36,8 @@ 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({
|
||||||
@@ -37,12 +48,16 @@ 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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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');
|
||||||
|
|
||||||
@@ -19,7 +21,8 @@ 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 ==========');
|
||||||
@@ -91,9 +94,17 @@ 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));
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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');
|
||||||
|
|
||||||
@@ -27,7 +29,8 @@ 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);
|
||||||
@@ -83,9 +86,17 @@ ${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,
|
||||||
@@ -269,7 +280,13 @@ ${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(projectPath, events, featureAbortController, maxFeatures);
|
await generateFeaturesFromSpec(
|
||||||
|
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);
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ 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(events: EventEmitter): Router {
|
export function createSpecRegenerationRoutes(
|
||||||
|
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));
|
router.post('/generate', createGenerateHandler(events, settingsService));
|
||||||
router.post('/generate-features', createGenerateFeaturesHandler(events));
|
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
|
||||||
router.post('/stop', createStopHandler());
|
router.post('/stop', createStopHandler());
|
||||||
router.get('/status', createStatusHandler());
|
router.get('/status', createStatusHandler());
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ 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(events: EventEmitter) {
|
export function createGenerateFeaturesHandler(
|
||||||
|
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));
|
||||||
@@ -49,7 +53,7 @@ export function createGenerateFeaturesHandler(events: EventEmitter) {
|
|||||||
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)
|
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||||
.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', {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ 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) {
|
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
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));
|
||||||
@@ -67,7 +68,8 @@ export function createGenerateHandler(events: EventEmitter) {
|
|||||||
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');
|
||||||
|
|||||||
39
apps/server/src/routes/backlog-plan/common.ts
Normal file
39
apps/server/src/routes/backlog-plan/common.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
217
apps/server/src/routes/backlog-plan/generate-plan.ts
Normal file
217
apps/server/src/routes/backlog-plan/generate-plan.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/server/src/routes/backlog-plan/index.ts
Normal file
30
apps/server/src/routes/backlog-plan/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
147
apps/server/src/routes/backlog-plan/routes/apply.ts
Normal file
147
apps/server/src/routes/backlog-plan/routes/apply.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
62
apps/server/src/routes/backlog-plan/routes/generate.ts
Normal file
62
apps/server/src/routes/backlog-plan/routes/generate.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
18
apps/server/src/routes/backlog-plan/routes/status.ts
Normal file
18
apps/server/src/routes/backlog-plan/routes/status.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
22
apps/server/src/routes/backlog-plan/routes/stop.ts
Normal file
22
apps/server/src/routes/backlog-plan/routes/stop.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,17 +8,19 @@
|
|||||||
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(): Router {
|
export function createContextRoutes(settingsService?: SettingsService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/describe-image', createDescribeImageHandler());
|
router.post('/describe-image', createDescribeImageHandler(settingsService));
|
||||||
router.post('/describe-file', createDescribeFileHandler());
|
router.post('/describe-file', createDescribeFileHandler(settingsService));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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');
|
||||||
|
|
||||||
@@ -72,9 +74,12 @@ 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(): (req: Request, res: Response) => Promise<void> {
|
export function createDescribeFileHandler(
|
||||||
|
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;
|
||||||
@@ -165,6 +170,13 @@ 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({
|
||||||
@@ -172,6 +184,7 @@ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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');
|
||||||
|
|
||||||
@@ -226,9 +228,12 @@ 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(): (req: Request, res: Response) => Promise<void> {
|
export function createDescribeImageHandler(
|
||||||
|
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();
|
||||||
@@ -325,12 +330,20 @@ export function createDescribeImageHandler(): (req: Request, res: Response) => P
|
|||||||
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ 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(events: EventEmitter): Router {
|
export function createGitHubRoutes(
|
||||||
|
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());
|
||||||
@@ -26,7 +30,7 @@ export function createGitHubRoutes(events: EventEmitter): Router {
|
|||||||
router.post(
|
router.post(
|
||||||
'/validate-issue',
|
'/validate-issue',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createValidateIssueHandler(events)
|
createValidateIssueHandler(events, settingsService)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validation management endpoints
|
// Validation management endpoints
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ 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;
|
||||||
@@ -54,7 +56,8 @@ 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 = {
|
||||||
@@ -76,12 +79,20 @@ 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>,
|
||||||
@@ -190,7 +201,10 @@ 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(events: EventEmitter) {
|
export function createValidateIssueHandler(
|
||||||
|
events: EventEmitter,
|
||||||
|
settingsService?: SettingsService
|
||||||
|
) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -256,7 +270,8 @@ export function createValidateIssueHandler(events: EventEmitter) {
|
|||||||
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)
|
||||||
|
|||||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/server/src/routes/pipeline/common.ts
Normal file
21
apps/server/src/routes/pipeline/common.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
77
apps/server/src/routes/pipeline/index.ts
Normal file
77
apps/server/src/routes/pipeline/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
54
apps/server/src/routes/pipeline/routes/add-step.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
42
apps/server/src/routes/pipeline/routes/delete-step.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
35
apps/server/src/routes/pipeline/routes/get-config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
42
apps/server/src/routes/pipeline/routes/reorder-steps.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
43
apps/server/src/routes/pipeline/routes/save-config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
50
apps/server/src/routes/pipeline/routes/update-step.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { logError } from '../common.js';
|
import { getErrorMessage, 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: 'Failed to retrieve credentials' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,71 +11,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 type { Credentials } from '../../../types/settings.js';
|
import type { Credentials } from '../../../types/settings.js';
|
||||||
import { logError } from '../common.js';
|
import { getErrorMessage, 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
|
||||||
@@ -86,19 +22,16 @@ function validateCredentialsUpdate(updates: unknown): string | null {
|
|||||||
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 {
|
||||||
// Validate the request body before type assertion
|
const updates = req.body as Partial<Credentials>;
|
||||||
const validationError = validateCredentialsUpdate(req.body);
|
|
||||||
if (validationError) {
|
if (!updates || typeof updates !== 'object') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: validationError,
|
error: 'Invalid request body - expected credentials object',
|
||||||
});
|
});
|
||||||
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
|
||||||
@@ -110,7 +43,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: 'Failed to update credentials' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ 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> = {};
|
||||||
|
|
||||||
@@ -44,32 +33,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -84,24 +47,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape the value for safe .env file storage
|
// Parse existing env content
|
||||||
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(`^${escapeRegExp(key)}=`);
|
const keyRegex = new RegExp(`^${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}=${escapedValue}`;
|
return `${key}=${value}`;
|
||||||
}
|
}
|
||||||
return line;
|
return line;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
// Add the key at the end
|
// Add the key at the end
|
||||||
newLines.push(`${key}=${escapedValue}`);
|
newLines.push(`${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(envPath, newLines.join('\n'));
|
await fs.writeFile(envPath, newLines.join('\n'));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getApiKey, logError } from '../common.js';
|
import { getApiKey, getErrorMessage, 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: 'Failed to retrieve API keys status' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, escapeRegExp } from '../common.js';
|
import { setApiKey } 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(`^${escapeRegExp(key)}=`);
|
const keyRegex = new RegExp(`^${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,10 +68,9 @@ 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. Only anthropic is supported.',
|
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,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: 'Failed to delete API key',
|
error: error instanceof Error ? error.message : 'Failed to delete API key',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { setApiKey, persistApiKeyToEnv, logError } from '../common.js';
|
import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('Setup');
|
const logger = createLogger('Setup');
|
||||||
@@ -30,10 +30,9 @@ 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. Only anthropic is supported.',
|
error: `Unsupported provider: ${provider}. Only anthropic is supported.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,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: 'Failed to store API key' });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,41 +10,6 @@ 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',
|
||||||
@@ -103,79 +68,14 @@ 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 {
|
||||||
// Validate and extract the auth method from the request body
|
// Get the auth method from the request body
|
||||||
let authMethod: AuthMethod | undefined;
|
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||||
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);
|
||||||
@@ -184,7 +84,7 @@ export function createVerifyClaudeAuthHandler() {
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let receivedAnyContent = false;
|
let receivedAnyContent = false;
|
||||||
|
|
||||||
// Save original env values (inside mutex to ensure consistency)
|
// Save original env values
|
||||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -199,8 +99,17 @@ 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
|
||||||
@@ -220,8 +129,7 @@ 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);
|
||||||
// Debug log only message type to avoid leaking sensitive data
|
logger.info('[Setup] Stream message:', msgStr.substring(0, 500));
|
||||||
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)) {
|
||||||
@@ -313,8 +221,7 @@ 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');
|
||||||
// Log only message count to avoid leaking sensitive data
|
logger.warn('[Setup] All messages:', allMessages.join('\n'));
|
||||||
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) {
|
||||||
@@ -370,8 +277,6 @@ 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:', {
|
||||||
|
|||||||
@@ -6,9 +6,94 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +127,8 @@ 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.',
|
||||||
@@ -51,8 +137,13 @@ export async function generateSuggestions(
|
|||||||
performance: 'Analyze this project for performance issues and suggest optimizations.',
|
performance: 'Analyze this project for performance issues and suggest optimizations.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
// Load existing context to avoid duplicates
|
||||||
|
const existingContext = await loadExistingContext(projectPath);
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -66,9 +157,17 @@ 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,
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ 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(events: EventEmitter): Router {
|
export function createSuggestionsRoutes(
|
||||||
|
events: EventEmitter,
|
||||||
|
settingsService?: SettingsService
|
||||||
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/generate', validatePathParams('projectPath'), createGenerateHandler(events));
|
router.post(
|
||||||
|
'/generate',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createGenerateHandler(events, settingsService)
|
||||||
|
);
|
||||||
router.post('/stop', createStopHandler());
|
router.post('/stop', createStopHandler());
|
||||||
router.get('/status', createStatusHandler());
|
router.get('/status', createStatusHandler());
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ 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) {
|
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
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 {
|
||||||
@@ -37,7 +38,7 @@ export function createGenerateHandler(events: EventEmitter) {
|
|||||||
setRunningState(true, abortController);
|
setRunningState(true, abortController);
|
||||||
|
|
||||||
// Start generation in background
|
// Start generation in background
|
||||||
generateSuggestions(projectPath, suggestionType, events, abortController)
|
generateSuggestions(projectPath, suggestionType, events, abortController, settingsService)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logError(error, 'Generate suggestions failed (background)');
|
logError(error, 'Generate suggestions failed (background)');
|
||||||
events.emit('suggestions:event', {
|
events.emit('suggestions:event', {
|
||||||
|
|||||||
@@ -65,18 +65,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -128,9 +116,8 @@ export function terminalAuthMiddleware(req: Request, res: Response, next: NextFu
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from Authorization header only (Bearer token format)
|
// Check for session token
|
||||||
// Query string tokens are not supported due to security risks (URL logging, referrer leakage)
|
const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string);
|
||||||
const token = extractBearerToken(req);
|
|
||||||
|
|
||||||
if (!validateTerminalToken(token)) {
|
if (!validateTerminalToken(token)) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 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,
|
||||||
@@ -13,25 +11,6 @@ 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 => {
|
||||||
@@ -57,28 +36,9 @@ 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 || !secureCompare(password, terminalPassword)) {
|
if (!password || 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',
|
||||||
@@ -86,9 +46,6 @@ 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();
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 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, extractBearerToken, validateTerminalToken } from '../common.js';
|
import { deleteToken } from '../common.js';
|
||||||
|
|
||||||
export function createLogoutHandler() {
|
export function createLogoutHandler() {
|
||||||
return (req: Request, res: Response): void => {
|
return (req: Request, res: Response): void => {
|
||||||
const token = extractBearerToken(req);
|
const token = (req.headers['x-terminal-token'] as string) || req.body.token;
|
||||||
|
|
||||||
if (!token) {
|
if (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);
|
deleteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -56,21 +56,31 @@ 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
|
||||||
|
console.log(`[CreatePR] Running: git add -A`);
|
||||||
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
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,
|
||||||
@@ -82,6 +92,20 @@ export function createCreatePRHandler() {
|
|||||||
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
|
||||||
@@ -360,8 +384,9 @@ export function createCreatePRHandler() {
|
|||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
committed: hasChanges,
|
committed: hasChanges && !commitError,
|
||||||
commitHash,
|
commitHash,
|
||||||
|
commitError: commitError || undefined,
|
||||||
pushed: true,
|
pushed: true,
|
||||||
prUrl,
|
prUrl,
|
||||||
prNumber,
|
prNumber,
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ 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;
|
||||||
@@ -30,6 +38,14 @@ 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;
|
||||||
@@ -37,6 +53,7 @@ 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 {
|
||||||
@@ -57,11 +74,13 @@ 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) {
|
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) {
|
||||||
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> {
|
||||||
@@ -90,12 +109,16 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +148,12 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +199,11 @@ 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',
|
||||||
@@ -186,12 +216,35 @@ 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 { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
|
const contextResult = 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
|
||||||
@@ -205,6 +258,11 @@ 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
|
||||||
@@ -215,21 +273,22 @@ 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: combinedSystemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
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
|
||||||
@@ -254,7 +313,6 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -319,6 +377,9 @@ 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,
|
||||||
@@ -557,6 +618,165 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||||
import type { ExecuteOptions, Feature } from '@automaker/types';
|
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
buildPromptWithImages,
|
buildPromptWithImages,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
@@ -25,8 +25,21 @@ 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 { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import {
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -341,9 +354,11 @@ 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) {
|
constructor(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
this.events = events;
|
this.events = events;
|
||||||
|
this.settingsService = settingsService ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -551,14 +566,25 @@ 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 { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
|
const contextResult = 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
|
||||||
@@ -604,9 +630,27 @@ 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
|
||||||
@@ -650,6 +694,143 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -746,12 +927,23 @@ export class AutoModeService {
|
|||||||
// 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 { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
|
const contextResult = 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
|
||||||
|
|
||||||
@@ -879,6 +1071,7 @@ 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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1065,11 +1258,6 @@ 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()}`;
|
||||||
@@ -1097,13 +1285,32 @@ 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);
|
||||||
|
|
||||||
const options: ExecuteOptions = {
|
// Load autoLoadClaudeMd setting
|
||||||
prompt,
|
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,
|
model: analysisModel,
|
||||||
maxTurns: 5,
|
maxTurns: 5,
|
||||||
cwd: projectPath,
|
|
||||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||||
abortController,
|
abortController,
|
||||||
|
autoLoadClaudeMd,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: ExecuteOptions = {
|
||||||
|
prompt,
|
||||||
|
model: sdkOptions.model ?? analysisModel,
|
||||||
|
cwd: sdkOptions.cwd ?? projectPath,
|
||||||
|
maxTurns: sdkOptions.maxTurns,
|
||||||
|
allowedTools: sdkOptions.allowedTools as string[],
|
||||||
|
abortController,
|
||||||
|
settingSources: sdkOptions.settingSources,
|
||||||
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = provider.executeQuery(options);
|
const stream = provider.executeQuery(options);
|
||||||
@@ -1708,6 +1915,7 @@ 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;
|
||||||
@@ -1780,11 +1988,32 @@ 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
|
||||||
@@ -1823,7 +2052,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
systemPrompt: options?.systemPrompt,
|
systemPrompt: sdkOptions.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
|
||||||
@@ -2051,6 +2285,9 @@ 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 = '';
|
||||||
@@ -2188,6 +2425,9 @@ 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 = '';
|
||||||
@@ -2277,6 +2517,9 @@ 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) {
|
||||||
|
|||||||
208
apps/server/src/services/mcp-test-service.ts
Normal file
208
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
320
apps/server/src/services/pipeline-service.ts
Normal file
320
apps/server/src/services/pipeline-service.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
@@ -1,16 +1,6 @@
|
|||||||
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.
|
||||||
@@ -39,7 +29,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 } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
|
|
||||||
@@ -55,7 +45,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 } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
req.headers['x-api-key'] = 'wrong-key';
|
req.headers['x-api-key'] = 'wrong-key';
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
@@ -72,7 +62,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 } = createMockExpressContextWithSocket();
|
const { req, res, next } = createMockExpressContext();
|
||||||
req.headers['x-api-key'] = 'test-secret-key';
|
req.headers['x-api-key'] = 'test-secret-key';
|
||||||
|
|
||||||
authMiddleware(req, res, next);
|
authMiddleware(req, res, next);
|
||||||
@@ -123,197 +113,4 @@ 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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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' });
|
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
|
||||||
|
|
||||||
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,6 +212,27 @@ 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', () => {
|
||||||
@@ -219,7 +240,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' });
|
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
|
||||||
|
|
||||||
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);
|
||||||
@@ -252,6 +273,27 @@ 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', () => {
|
||||||
|
|||||||
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
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]'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: 'acceptEdits',
|
permissionMode: 'default',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -100,7 +100,7 @@ describe('claude-provider.ts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable sandbox by default', async () => {
|
it('should pass sandbox configuration when provided', 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,6 +110,10 @@ 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);
|
||||||
@@ -242,11 +246,21 @@ 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(
|
|
||||||
'[ClaudeProvider] executeQuery() error during execution:',
|
// Should log error message
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
499
apps/server/tests/unit/routes/pipeline.test.ts
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,9 +106,9 @@ describe('agent-service.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
// First call reads session file and metadata file (2 calls)
|
// First call reads session file, metadata file, and queue state file (3 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(2);
|
expect(fs.readFile).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
860
apps/server/tests/unit/services/pipeline-service.test.ts
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react';
|
||||||
FolderOpen,
|
|
||||||
Folder,
|
|
||||||
ChevronRight,
|
|
||||||
Home,
|
|
||||||
ArrowLeft,
|
|
||||||
HardDrive,
|
|
||||||
CornerDownLeft,
|
|
||||||
Clock,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,9 +9,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { PathInput } from '@/components/ui/path-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;
|
||||||
@@ -77,8 +69,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[]>([]);
|
||||||
@@ -86,7 +78,6 @@ 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(() => {
|
||||||
@@ -120,7 +111,6 @@ 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 || []);
|
||||||
@@ -142,11 +132,10 @@ export function FileBrowserDialog({
|
|||||||
[browseDirectory]
|
[browseDirectory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset current path when dialog closes
|
// Reset state when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCurrentPath('');
|
setCurrentPath('');
|
||||||
setPathInput('');
|
|
||||||
setParentPath(null);
|
setParentPath(null);
|
||||||
setDirectories([]);
|
setDirectories([]);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -172,9 +161,6 @@ 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
|
||||||
@@ -183,7 +169,6 @@ 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();
|
||||||
@@ -199,34 +184,21 @@ export function FileBrowserDialog({
|
|||||||
browseDirectory(dir.path);
|
browseDirectory(dir.path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoToParent = () => {
|
const handleGoHome = useCallback(() => {
|
||||||
if (parentPath) {
|
|
||||||
browseDirectory(parentPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoHome = () => {
|
|
||||||
browseDirectory();
|
browseDirectory();
|
||||||
};
|
}, [browseDirectory]);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
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);
|
||||||
@@ -263,7 +235,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">
|
<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">
|
||||||
<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" />
|
||||||
@@ -275,31 +247,21 @@ 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">
|
||||||
{/* Direct path input */}
|
{/* Path navigation */}
|
||||||
<div className="flex items-center gap-1.5">
|
<PathInput
|
||||||
<Input
|
currentPath={currentPath}
|
||||||
ref={pathInputRef}
|
parentPath={parentPath}
|
||||||
type="text"
|
loading={loading}
|
||||||
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
|
error={!!error}
|
||||||
value={pathInput}
|
onNavigate={handleNavigate}
|
||||||
onChange={(e) => setPathInput(e.target.value)}
|
onHome={handleGoHome}
|
||||||
onKeyDown={handlePathInputKeyDown}
|
entries={directories.map((dir) => ({ ...dir, isDirectory: true }))}
|
||||||
className="flex-1 font-mono text-xs h-8"
|
onSelectEntry={(entry) => {
|
||||||
data-testid="path-input"
|
if (entry.isDirectory) {
|
||||||
disabled={loading}
|
handleSelectDirectory(entry);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<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 && (
|
||||||
@@ -352,35 +314,8 @@ 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">
|
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
|
||||||
{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>
|
||||||
@@ -423,8 +358,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 Go to
|
Paste a full path above, or click on folders to navigate. Press Enter or click → to jump
|
||||||
jump to a path.
|
to a path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,12 +375,10 @@ 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
|
||||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
<KbdGroup className="ml-1">
|
||||||
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
|
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
|
||||||
? '⌘'
|
<Kbd>↵</Kbd>
|
||||||
: 'Ctrl'}
|
</KbdGroup>
|
||||||
+↵
|
|
||||||
</kbd>
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
useNavigation,
|
useNavigation,
|
||||||
useProjectCreation,
|
useProjectCreation,
|
||||||
useSetupDialog,
|
useSetupDialog,
|
||||||
useTrashDialog,
|
useTrashOperations,
|
||||||
useProjectTheme,
|
useProjectTheme,
|
||||||
useUnviewedValidations,
|
useUnviewedValidations,
|
||||||
} from './sidebar/hooks';
|
} from './sidebar/hooks';
|
||||||
@@ -68,6 +68,9 @@ 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();
|
||||||
|
|
||||||
@@ -131,20 +134,17 @@ export function Sidebar() {
|
|||||||
// Unviewed validations count
|
// Unviewed validations count
|
||||||
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
|
||||||
|
|
||||||
// Trash dialog and operations
|
// Trash operations
|
||||||
const {
|
const {
|
||||||
showTrashDialog,
|
|
||||||
setShowTrashDialog,
|
|
||||||
activeTrashId,
|
activeTrashId,
|
||||||
isEmptyingTrash,
|
isEmptyingTrash,
|
||||||
handleRestoreProject,
|
handleRestoreProject,
|
||||||
handleDeleteProjectFromDisk,
|
handleDeleteProjectFromDisk,
|
||||||
handleEmptyTrash,
|
handleEmptyTrash,
|
||||||
} = useTrashDialog({
|
} = useTrashOperations({
|
||||||
restoreTrashedProject,
|
restoreTrashedProject,
|
||||||
deleteTrashedProject,
|
deleteTrashedProject,
|
||||||
emptyTrash,
|
emptyTrash,
|
||||||
trashedProjects,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spec regeneration events
|
// Spec regeneration events
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { X, Trash2, Undo2 } from 'lucide-react';
|
import { X, Trash2, Undo2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -8,6 +9,8 @@ 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 {
|
||||||
@@ -33,8 +36,42 @@ 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>
|
||||||
@@ -72,7 +109,7 @@ export function TrashDialog({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => handleDeleteProjectFromDisk(project)}
|
onClick={() => onDeleteFromDiskClick(project)}
|
||||||
disabled={activeTrashId === project.id}
|
disabled={activeTrashId === project.id}
|
||||||
data-testid={`delete-project-disk-${project.id}`}
|
data-testid={`delete-project-disk-${project.id}`}
|
||||||
>
|
>
|
||||||
@@ -102,7 +139,7 @@ export function TrashDialog({
|
|||||||
{trashedProjects.length > 0 && (
|
{trashedProjects.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleEmptyTrash}
|
onClick={onEmptyTrashClick}
|
||||||
disabled={isEmptyingTrash}
|
disabled={isEmptyingTrash}
|
||||||
data-testid="empty-trash"
|
data-testid="empty-trash"
|
||||||
>
|
>
|
||||||
@@ -112,5 +149,33 @@ export function TrashDialog({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete from disk confirmation dialog */}
|
||||||
|
{deleteFromDiskProject && (
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(isOpen) => !isOpen && setDeleteFromDiskProject(null)}
|
||||||
|
onConfirm={onConfirmDeleteFromDisk}
|
||||||
|
title={`Delete "${deleteFromDiskProject.name}" from disk?`}
|
||||||
|
description="This sends the folder to your system Trash."
|
||||||
|
confirmText="Delete from disk"
|
||||||
|
testId="delete-from-disk-confirm-dialog"
|
||||||
|
confirmTestId="confirm-delete-from-disk-button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty trash confirmation dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showEmptyTrashConfirm}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,5 @@ 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';
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,23 +64,19 @@ 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, trashedProjects.length]);
|
}, [emptyTrash]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTrashId,
|
activeTrashId,
|
||||||
|
|||||||
102
apps/ui/src/components/ui/breadcrumb.tsx
Normal file
102
apps/ui/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
10
apps/ui/src/components/ui/collapsible.tsx
Normal file
10
apps/ui/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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 };
|
||||||
28
apps/ui/src/components/ui/kbd.tsx
Normal file
28
apps/ui/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 };
|
||||||
442
apps/ui/src/components/ui/path-input.tsx
Normal file
442
apps/ui/src/components/ui/path-input.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
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 };
|
||||||
@@ -18,6 +18,7 @@ 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';
|
||||||
@@ -86,6 +87,10 @@ 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,
|
||||||
@@ -134,11 +139,7 @@ export function AgentView() {
|
|||||||
}, [currentProject?.path]);
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (
|
if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return;
|
||||||
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
|
|
||||||
isProcessing
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const messageContent = input;
|
const messageContent = input;
|
||||||
const messageImages = selectedImages;
|
const messageImages = selectedImages;
|
||||||
@@ -149,8 +150,13 @@ export function AgentView() {
|
|||||||
setSelectedTextFiles([]);
|
setSelectedTextFiles([]);
|
||||||
setShowImageDropZone(false);
|
setShowImageDropZone(false);
|
||||||
|
|
||||||
|
// If already processing, add to server queue instead
|
||||||
|
if (isProcessing) {
|
||||||
|
await addToServerQueue(messageContent, messageImages, messageTextFiles);
|
||||||
|
} else {
|
||||||
await sendMessage(messageContent, messageImages, messageTextFiles);
|
await sendMessage(messageContent, messageImages, messageTextFiles);
|
||||||
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
|
}
|
||||||
|
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]);
|
||||||
|
|
||||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
||||||
setSelectedImages(images);
|
setSelectedImages(images);
|
||||||
@@ -536,41 +542,6 @@ 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" />
|
||||||
@@ -760,10 +731,52 @@ export function AgentView() {
|
|||||||
images={selectedImages}
|
images={selectedImages}
|
||||||
maxFiles={5}
|
maxFiles={5}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
disabled={isProcessing || !isConnected}
|
disabled={!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">
|
||||||
@@ -778,7 +791,6 @@ 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>
|
||||||
@@ -869,13 +881,17 @@ export function AgentView() {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
|
isDragOver
|
||||||
|
? '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={isProcessing || !isConnected}
|
disabled={!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',
|
||||||
@@ -899,12 +915,44 @@ 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={isProcessing || !isConnected}
|
disabled={!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',
|
||||||
@@ -916,8 +964,8 @@ export function AgentView() {
|
|||||||
<Paperclip className="w-4 h-4" />
|
<Paperclip className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Send / Stop Button */}
|
{/* Stop Button (only when processing) */}
|
||||||
{isProcessing ? (
|
{isProcessing && (
|
||||||
<Button
|
<Button
|
||||||
onClick={stopExecution}
|
onClick={stopExecution}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
@@ -928,7 +976,9 @@ export function AgentView() {
|
|||||||
>
|
>
|
||||||
<Square className="w-4 h-4 fill-current" />
|
<Square className="w-4 h-4 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* Send / Queue Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -938,11 +988,12 @@ export function AgentView() {
|
|||||||
!isConnected
|
!isConnected
|
||||||
}
|
}
|
||||||
className="h-11 px-4 rounded-xl"
|
className="h-11 px-4 rounded-xl"
|
||||||
|
variant={isProcessing ? 'outline' : 'default'}
|
||||||
data-testid="send-message"
|
data-testid="send-message"
|
||||||
|
title={isProcessing ? 'Add to queue' : 'Send message'}
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ 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';
|
||||||
@@ -25,6 +28,7 @@ import { GraphView } from './graph-view';
|
|||||||
import {
|
import {
|
||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
|
BacklogPlanDialog,
|
||||||
CompletedFeaturesModal,
|
CompletedFeaturesModal,
|
||||||
ArchiveAllVerifiedDialog,
|
ArchiveAllVerifiedDialog,
|
||||||
DeleteCompletedFeatureDialog,
|
DeleteCompletedFeatureDialog,
|
||||||
@@ -33,6 +37,7 @@ 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';
|
||||||
@@ -57,9 +62,6 @@ 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,
|
||||||
@@ -85,7 +87,10 @@ 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,
|
||||||
@@ -107,6 +112,9 @@ 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);
|
||||||
@@ -122,6 +130,14 @@ 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,
|
||||||
@@ -193,6 +209,25 @@ 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)
|
||||||
@@ -450,23 +485,22 @@ 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 and start it
|
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||||
// We need to wait a moment for the feature to be created
|
|
||||||
setTimeout(async () => {
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
const latestFeatures = useAppStore.getState().features;
|
||||||
const newFeature = latestFeatures.find(
|
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||||
(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.');
|
||||||
|
toast.error('Failed to auto-start feature', {
|
||||||
|
description: 'The feature was created but could not be started automatically.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, FEATURE_CREATION_SETTLE_DELAY_MS);
|
|
||||||
},
|
},
|
||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||||
);
|
);
|
||||||
@@ -492,26 +526,49 @@ 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 and start it
|
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||||
setTimeout(async () => {
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
const latestFeatures = useAppStore.getState().features;
|
||||||
const newFeature = latestFeatures.find(
|
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||||
(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.');
|
||||||
|
toast.error('Failed to auto-start feature', {
|
||||||
|
description: 'The feature was created but could not be started automatically.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, FEATURE_CREATION_SETTLE_DELAY_MS);
|
|
||||||
},
|
},
|
||||||
[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);
|
||||||
@@ -575,6 +632,37 @@ 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;
|
||||||
@@ -932,6 +1020,7 @@ 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),
|
||||||
@@ -1021,6 +1110,10 @@ 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}
|
||||||
@@ -1028,6 +1121,10 @@ 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
|
||||||
@@ -1043,6 +1140,12 @@ 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>
|
||||||
@@ -1077,8 +1180,14 @@ export function BoardView() {
|
|||||||
{/* Add Feature Dialog */}
|
{/* Add Feature Dialog */}
|
||||||
<AddFeatureDialog
|
<AddFeatureDialog
|
||||||
open={showAddDialog}
|
open={showAddDialog}
|
||||||
onOpenChange={setShowAddDialog}
|
onOpenChange={(open) => {
|
||||||
|
setShowAddDialog(open);
|
||||||
|
if (!open) {
|
||||||
|
setSpawnParentFeature(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onAdd={handleAddFeature}
|
onAdd={handleAddFeature}
|
||||||
|
onAddAndStart={handleAddAndStartFeature}
|
||||||
categorySuggestions={categorySuggestions}
|
categorySuggestions={categorySuggestions}
|
||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
@@ -1088,6 +1197,8 @@ 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 */}
|
||||||
@@ -1126,6 +1237,22 @@ 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}
|
||||||
@@ -1152,6 +1279,18 @@ 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}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 } from 'lucide-react';
|
import { Plus, Bot, Wand2 } 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';
|
||||||
@@ -15,6 +16,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,7 @@ export function BoardHeader({
|
|||||||
isAutoModeRunning,
|
isAutoModeRunning,
|
||||||
onAutoModeToggle,
|
onAutoModeToggle,
|
||||||
onAddFeature,
|
onAddFeature,
|
||||||
|
onOpenPlanDialog,
|
||||||
addFeatureShortcut,
|
addFeatureShortcut,
|
||||||
isMounted,
|
isMounted,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
@@ -89,6 +92,16 @@ 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}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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';
|
||||||
@@ -31,6 +32,7 @@ interface CardHeaderProps {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
|
onSpawnTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export function CardHeaderSection({
|
||||||
@@ -40,6 +42,7 @@ 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);
|
||||||
@@ -92,6 +95,17 @@ 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">
|
||||||
@@ -106,7 +120,21 @@ export function CardHeaderSection({
|
|||||||
|
|
||||||
{/* Backlog header */}
|
{/* Backlog header */}
|
||||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
|
<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"
|
||||||
@@ -141,6 +169,22 @@ 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"
|
||||||
@@ -229,6 +273,17 @@ 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">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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;
|
||||||
@@ -51,6 +52,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onViewPlan,
|
onViewPlan,
|
||||||
onApprovePlan,
|
onApprovePlan,
|
||||||
|
onSpawnTask,
|
||||||
hasContext,
|
hasContext,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
@@ -146,6 +148,7 @@ 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">
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
|
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
|
|
||||||
export type ColumnId = Feature['status'];
|
export type ColumnId = Feature['status'];
|
||||||
|
|
||||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
export interface Column {
|
||||||
|
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',
|
||||||
@@ -20,3 +34,58 @@ export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
|||||||
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_', '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +37,7 @@ import {
|
|||||||
FeatureImage,
|
FeatureImage,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
|
Feature,
|
||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
BranchSelector,
|
||||||
PlanningModeSelector,
|
PlanningModeSelector,
|
||||||
|
AncestorContextSection,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -54,11 +56,13 @@ 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';
|
||||||
|
|
||||||
interface AddFeatureDialogProps {
|
type FeatureData = {
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onAdd: (feature: {
|
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -72,7 +76,14 @@ interface AddFeatureDialogProps {
|
|||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
}) => void;
|
dependencies?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddFeatureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAdd: (feature: FeatureData) => void;
|
||||||
|
onAddAndStart?: (feature: FeatureData) => 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
|
||||||
@@ -82,12 +93,16 @@ 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,
|
||||||
@@ -97,7 +112,10 @@ 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({
|
||||||
@@ -125,6 +143,10 @@ 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,
|
||||||
@@ -153,6 +175,17 @@ 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,
|
||||||
@@ -162,18 +195,20 @@ export function AddFeatureDialog({
|
|||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
defaultAIProfileId,
|
defaultAIProfileId,
|
||||||
aiProfiles,
|
aiProfiles,
|
||||||
|
parentFeature,
|
||||||
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const buildFeatureData = (): FeatureData | null => {
|
||||||
if (!newFeature.description.trim()) {
|
if (!newFeature.description.trim()) {
|
||||||
setDescriptionError(true);
|
setDescriptionError(true);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = newFeature.category || 'Uncategorized';
|
const category = newFeature.category || 'Uncategorized';
|
||||||
@@ -187,10 +222,34 @@ 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 || '';
|
||||||
|
|
||||||
onAdd({
|
// Build final description - prepend ancestor context in spawn mode
|
||||||
|
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: newFeature.description,
|
description: finalDescription,
|
||||||
images: newFeature.images,
|
images: newFeature.images,
|
||||||
imagePaths: newFeature.imagePaths,
|
imagePaths: newFeature.imagePaths,
|
||||||
textFilePaths: newFeature.textFilePaths,
|
textFilePaths: newFeature.textFilePaths,
|
||||||
@@ -201,9 +260,12 @@ 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Reset form
|
const resetForm = () => {
|
||||||
setNewFeature({
|
setNewFeature({
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
category: '',
|
||||||
@@ -226,6 +288,20 @@ 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) {
|
||||||
@@ -299,8 +375,12 @@ export function AddFeatureDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add New Feature</DialogTitle>
|
<DialogTitle>{isSpawnMode ? 'Spawn Sub-Task' : 'Add New Feature'}</DialogTitle>
|
||||||
<DialogDescription>Create a new feature card for the Kanban board.</DialogDescription>
|
<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">
|
||||||
@@ -320,6 +400,22 @@ 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
|
||||||
@@ -505,6 +601,17 @@ 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 }}
|
||||||
@@ -512,7 +619,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()}
|
||||||
>
|
>
|
||||||
Add Feature
|
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
||||||
</HotkeyButton>
|
</HotkeyButton>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -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" />
|
||||||
Archive All
|
Complete All
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ export function DependencyTreeDialog({
|
|||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{dep.status.replace(/_/g, ' ')}
|
{(dep.status || 'backlog').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.replace(/_/g, ' ')}
|
{(dependent.status || 'backlog').replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -0,0 +1,736 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ 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)
|
||||||
@@ -150,6 +151,7 @@ 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
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ 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(() => {
|
||||||
const map: Record<ColumnId, Feature[]> = {
|
// Use a more flexible type to support dynamic pipeline statuses
|
||||||
|
const map: Record<string, Feature[]> = {
|
||||||
backlog: [],
|
backlog: [],
|
||||||
in_progress: [],
|
in_progress: [],
|
||||||
waiting_approval: [],
|
waiting_approval: [],
|
||||||
@@ -76,15 +77,33 @@ 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) {
|
||||||
// Only show running tasks if they match the current worktree
|
if (!matchesWorktree) return;
|
||||||
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);
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
}
|
||||||
const status = f.status as ColumnId;
|
|
||||||
|
|
||||||
|
// Not running: place by status (and worktree filter)
|
||||||
// Filter all items by worktree, including backlog
|
// Filter all items by worktree, including backlog
|
||||||
// This ensures backlog items with a branch assigned only show in that branch
|
// This ensures backlog items with a branch assigned only show in that branch
|
||||||
if (status === 'backlog') {
|
if (status === 'backlog') {
|
||||||
@@ -96,13 +115,20 @@ export function useBoardColumnFeatures({
|
|||||||
if (matchesWorktree) {
|
if (matchesWorktree) {
|
||||||
map[status].push(f);
|
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
|
// Unknown status, default to backlog
|
||||||
if (matchesWorktree) {
|
if (matchesWorktree) {
|
||||||
map.backlog.push(f);
|
map.backlog.push(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply dependency-aware sorting to backlog
|
// Apply dependency-aware sorting to backlog
|
||||||
@@ -147,7 +173,7 @@ export function useBoardColumnFeatures({
|
|||||||
|
|
||||||
const getColumnFeatures = useCallback(
|
const getColumnFeatures = useCallback(
|
||||||
(columnId: ColumnId) => {
|
(columnId: ColumnId) => {
|
||||||
return columnFeaturesMap[columnId];
|
return columnFeaturesMap[columnId] || [];
|
||||||
},
|
},
|
||||||
[columnFeaturesMap]
|
[columnFeaturesMap]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -203,6 +203,11 @@ 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
Reference in New Issue
Block a user