mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
7 Commits
coderabbit
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51641bad9c | ||
|
|
a1c5d0cb0d | ||
|
|
0bb9db5f80 | ||
|
|
5ec19f11cd | ||
|
|
85dc631250 | ||
|
|
460afa82b8 | ||
|
|
79ef8c8510 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,5 +79,4 @@ blob-report/
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
.claude/
|
||||
docker-compose.override.yml
|
||||
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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 - a Kanban-based application where users describe features and AI agents (powered by Claude Agent SDK) automatically implement them. It runs as an Electron desktop app or in web browser mode.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build shared packages (REQUIRED before running)
|
||||
npm run build:packages
|
||||
|
||||
# Development
|
||||
npm run dev # Interactive mode selector
|
||||
npm run dev:electron # Electron desktop app
|
||||
npm run dev:web # Web browser mode (localhost:3007)
|
||||
npm run dev:server # Backend server only
|
||||
|
||||
# Testing
|
||||
npm run test # UI E2E tests (Playwright)
|
||||
npm run test:headed # UI tests with visible browser
|
||||
npm run test:server # Server unit tests (Vitest)
|
||||
npm run test:packages # Shared package tests
|
||||
|
||||
# Linting & Formatting
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier
|
||||
npm run format:check # Check formatting
|
||||
|
||||
# Building
|
||||
npm run build # Build Next.js app
|
||||
npm run build:electron # Build Electron distribution
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (npm workspaces)
|
||||
|
||||
```
|
||||
apps/
|
||||
├── ui/ # Electron + Vite + React frontend (@automaker/ui)
|
||||
└── server/ # Express + WebSocket backend (@automaker/server)
|
||||
|
||||
libs/ # Shared packages (@automaker/*)
|
||||
├── types/ # Shared TypeScript interfaces
|
||||
├── utils/ # Common utilities
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Platform-specific code (paths, security)
|
||||
├── git-utils/ # Git operations
|
||||
├── model-resolver/ # AI model configuration
|
||||
└── dependency-resolver/ # Dependency management
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**State Management (UI)**: Zustand stores in `apps/ui/src/store/`
|
||||
|
||||
- `app-store.ts` - Main application state (features, settings, themes)
|
||||
- `setup-store.ts` - Project setup wizard state
|
||||
|
||||
**Routing (UI)**: TanStack Router with file-based routes in `apps/ui/src/routes/`
|
||||
|
||||
**Backend Services**: Express + WebSocket in `apps/server/src/`
|
||||
|
||||
- Services in `/services/` handle business logic
|
||||
- Routes in `/routes/` define API endpoints
|
||||
- Providers in `/providers/` abstract AI model integrations
|
||||
|
||||
**Provider Architecture**: Model-based routing via `ProviderFactory`
|
||||
|
||||
- `ClaudeProvider` wraps @anthropic-ai/claude-agent-sdk
|
||||
- Designed for easy addition of other providers
|
||||
|
||||
**Feature Storage**: Features stored in `.automaker/features/{id}/feature.json`
|
||||
|
||||
**Communication**:
|
||||
|
||||
- Electron: IPC via preload script (`apps/ui/src/preload.ts`)
|
||||
- Web: HTTP API client (`apps/ui/src/lib/http-api-client.ts`)
|
||||
|
||||
### Important Files
|
||||
|
||||
- `apps/ui/src/main.ts` - Electron main process
|
||||
- `apps/server/src/index.ts` - Server entry point
|
||||
- `apps/ui/src/lib/electron.ts` - IPC type definitions
|
||||
- `apps/server/src/services/agent-service.ts` - AI agent session management
|
||||
- `apps/server/src/providers/provider-factory.ts` - Model routing
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Always run `npm run build:packages` after modifying any `libs/*` package
|
||||
- Server runs on port 3008 by default
|
||||
- UI runs on port 3007 in web mode
|
||||
- Authentication: Set `ANTHROPIC_API_KEY` env var or configure via Settings
|
||||
412
README.md
412
README.md
@@ -19,7 +19,7 @@
|
||||
|
||||
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||
- [The Workflow](#the-workflow)
|
||||
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
|
||||
- [Powered by Claude Code](#powered-by-claude-code)
|
||||
- [Why This Matters](#why-this-matters)
|
||||
- [Security Disclaimer](#security-disclaimer)
|
||||
- [Community & Support](#community--support)
|
||||
@@ -28,36 +28,22 @@
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
|
||||
- [Web Browser Mode](#web-browser-mode)
|
||||
- [Building for Production](#building-for-production)
|
||||
- [Running Production Build](#running-production-build)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
- [Environment Configuration](#environment-configuration)
|
||||
- [Authentication Setup](#authentication-setup)
|
||||
- [Authentication Options](#authentication-options)
|
||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||
- [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)
|
||||
- [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)
|
||||
- [License](#license)
|
||||
|
||||
</details>
|
||||
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution.
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||
|
||||

|
||||
|
||||
@@ -73,9 +59,9 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
||||
4. **Review & Verify** - Review the changes, run tests, and approve when ready
|
||||
5. **Ship Faster** - Build entire applications in days, not weeks
|
||||
|
||||
### Powered by Claude Agent SDK
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
@@ -109,7 +95,8 @@ In the Discord, you can:
|
||||
- 🚀 Show off projects built with AI agents
|
||||
- 🤝 Collaborate with other developers and contributors
|
||||
|
||||
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
👉 **Join the Discord:**
|
||||
https://discord.gg/jjem7aEDKU
|
||||
|
||||
---
|
||||
|
||||
@@ -117,49 +104,28 @@ In the Discord, you can:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 18+** (tested with Node.js 22)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Authentication** (choose one):
|
||||
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
|
||||
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||
# 3. Build local shared packages
|
||||
npm run build:packages
|
||||
|
||||
# 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)
|
||||
# 4. Run Automaker (pick your mode)
|
||||
npm run dev
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
### Development Mode
|
||||
@@ -197,65 +163,31 @@ npm run dev:web
|
||||
|
||||
### Building for Production
|
||||
|
||||
#### Web Application
|
||||
|
||||
```bash
|
||||
# Build for web deployment (uses Vite)
|
||||
# Build Next.js app
|
||||
npm run build
|
||||
|
||||
# Run production build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Desktop Application
|
||||
|
||||
```bash
|
||||
# Build for current platform (macOS/Windows/Linux)
|
||||
# Build Electron app for distribution
|
||||
npm run build:electron
|
||||
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
|
||||
npm run build:electron:win # Windows (NSIS installer, x64)
|
||||
npm run build:electron:linux # Linux (AppImage + DEB, x64)
|
||||
|
||||
# Output directory: apps/ui/release/
|
||||
```
|
||||
|
||||
#### Docker Deployment
|
||||
### Running Production Build
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose (recommended for security)
|
||||
docker-compose up -d
|
||||
|
||||
# Access at http://localhost:3007
|
||||
# API at http://localhost:3008
|
||||
# Start production Next.js server
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
|
||||
```bash
|
||||
npm run test # Headless E2E tests
|
||||
npm run test:headed # Browser visible E2E tests
|
||||
# Run tests headless
|
||||
npm run test
|
||||
|
||||
# Run tests with browser visible
|
||||
npm run test:headed
|
||||
```
|
||||
|
||||
#### Unit Tests (Vitest)
|
||||
|
||||
```bash
|
||||
npm run test:server # Server unit tests
|
||||
npm run test:server:coverage # Server tests with coverage
|
||||
npm run test:packages # All shared package tests
|
||||
npm run test:all # Packages + server tests
|
||||
```
|
||||
|
||||
#### Test Configuration
|
||||
|
||||
- E2E tests run on ports 3007 (UI) and 3008 (server)
|
||||
- Automatically starts test servers before running
|
||||
- Uses Chromium browser via Playwright
|
||||
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
@@ -263,283 +195,59 @@ npm run test:all # Packages + server tests
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
### Authentication Options
|
||||
|
||||
#### Authentication (if not using Claude Code CLI)
|
||||
Automaker supports multiple authentication methods (in order of priority):
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
|
||||
| Method | Environment Variable | Description |
|
||||
| ---------------- | -------------------- | ------------------------------- |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
|
||||
#### Optional - Server
|
||||
|
||||
- `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
|
||||
### Persistent Setup (Optional)
|
||||
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
|
||||
|
||||
##### 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.
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Workflow
|
||||
|
||||
- 📋 **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"
|
||||
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
|
||||
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
|
||||
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
|
||||
|
||||
### AI & Planning
|
||||
|
||||
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
|
||||
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
|
||||
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
|
||||
- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins
|
||||
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
|
||||
|
||||
### Project Management
|
||||
|
||||
- 🔍 **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
|
||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
- **React 19** - UI framework
|
||||
- **Vite 7** - Build tool and development server
|
||||
- **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
|
||||
```
|
||||
- [Next.js](https://nextjs.org) - React framework
|
||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
||||
|
||||
## Learn More
|
||||
|
||||
### Documentation
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||
- [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)
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
1282
apps/app/server-bundle/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
apps/app/server-bundle/package.json
Normal file
15
apps/app/server-bundle/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@automaker/server-bundle",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"morgan": "^1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,6 @@ import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/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';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -113,11 +111,10 @@ app.use(express.json({ limit: '50mb' }));
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
|
||||
// Create services
|
||||
// Note: settingsService is created first so it can be injected into other services
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const autoModeService = new AutoModeService(events);
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
|
||||
// Initialize services
|
||||
@@ -126,15 +123,6 @@ const claudeUsageService = new ClaudeUsageService();
|
||||
console.log('[Server] Agent service initialized');
|
||||
})();
|
||||
|
||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
setInterval(() => {
|
||||
const cleaned = cleanupStaleValidations();
|
||||
if (cleaned > 0) {
|
||||
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
|
||||
@@ -150,18 +138,17 @@ app.use('/api/enhance-prompt', createEnhancePromptRoutes());
|
||||
app.use('/api/worktree', createWorktreeRoutes());
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/setup', createSetupRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events));
|
||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
||||
app.use('/api/workspace', createWorkspaceRoutes());
|
||||
app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/github', createGitHubRoutes());
|
||||
app.use('/api/context', createContextRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -190,31 +177,12 @@ server.on('upgrade', (request, socket, head) => {
|
||||
|
||||
// Events WebSocket connection handler
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
console.log('[WebSocket] Client connected, ready state:', ws.readyState);
|
||||
console.log('[WebSocket] Client connected');
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
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) {
|
||||
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
|
||||
);
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -224,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[WebSocket] ERROR:', error);
|
||||
console.error('[WebSocket] Error:', error);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Enhancement Prompts - Re-exported from @automaker/prompts
|
||||
*
|
||||
* This file now re-exports enhancement prompts from the shared @automaker/prompts package
|
||||
* to maintain backward compatibility with existing imports in the server codebase.
|
||||
*/
|
||||
|
||||
export {
|
||||
IMPROVE_SYSTEM_PROMPT,
|
||||
TECHNICAL_SYSTEM_PROMPT,
|
||||
SIMPLIFY_SYSTEM_PROMPT,
|
||||
ACCEPTANCE_SYSTEM_PROMPT,
|
||||
IMPROVE_EXAMPLES,
|
||||
TECHNICAL_EXAMPLES,
|
||||
SIMPLIFY_EXAMPLES,
|
||||
ACCEPTANCE_EXAMPLES,
|
||||
getEnhancementPrompt,
|
||||
getSystemPrompt,
|
||||
getExamples,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
getAvailableEnhancementModes,
|
||||
} from '@automaker/prompts';
|
||||
|
||||
export type { EnhancementMode, EnhancementExample } from '@automaker/prompts';
|
||||
69
apps/server/src/lib/exec-utils.ts
Normal file
69
apps/server/src/lib/exec-utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Shell execution utilities
|
||||
*
|
||||
* Provides cross-platform shell execution with extended PATH
|
||||
* to find tools like git and gh in Electron environments.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
/**
|
||||
* Promisified exec for async/await usage
|
||||
*/
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Path separator for the current platform
|
||||
*/
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
|
||||
/**
|
||||
* Additional paths to search for executables.
|
||||
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||
* common tool installation locations.
|
||||
*/
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows paths for Git and other tools
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env['ProgramFiles(x86)']) {
|
||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin` // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended PATH that includes common tool installation locations.
|
||||
*/
|
||||
export const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||
.filter(Boolean)
|
||||
.join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||
*/
|
||||
export function isENOENT(error: unknown): boolean {
|
||||
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
@@ -136,59 +136,6 @@ function getBaseOptions(): Partial<Options> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -213,12 +160,6 @@ export interface CreateSdkOptionsConfig {
|
||||
type: 'json_schema';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,15 +169,11 @@ export interface CreateSdkOptionsConfig {
|
||||
* - Uses read-only tools for codebase analysis
|
||||
* - Extended turns for thorough exploration
|
||||
* - 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 {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - spec generation only needs read-only tools
|
||||
@@ -247,7 +184,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
...claudeMdOptions,
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
@@ -260,15 +197,11 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
||||
* - Uses read-only tools (just needs to read the spec)
|
||||
* - Quick turns since it's mostly JSON generation
|
||||
* - 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 {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
@@ -277,7 +210,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
@@ -289,22 +222,18 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
||||
* - Uses read-only tools for analysis
|
||||
* - Standard turns to allow thorough codebase exploration and structured output generation
|
||||
* - 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 {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('suggestions', config.model),
|
||||
maxTurns: MAX_TURNS.extended,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
...(config.outputFormat && { outputFormat: config.outputFormat }),
|
||||
};
|
||||
@@ -317,8 +246,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
* - Full tool access for code modification
|
||||
* - Standard turns for interactive sessions
|
||||
* - Model priority: explicit model > session model > chat default
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
@@ -327,22 +255,17 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Model priority: explicit model > session model > chat default
|
||||
const effectiveModel = config.model || config.sessionModel;
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
@@ -354,29 +277,23 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* - Full tool access for code modification and implementation
|
||||
* - Extended turns for thorough feature implementation
|
||||
* - Uses default model (can be overridden)
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
* - Sandbox enabled for bash safety
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
...(config.enableSandboxMode && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
@@ -385,7 +302,6 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* Create custom SDK options with explicit configuration
|
||||
*
|
||||
* 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(
|
||||
config: CreateSdkOptionsConfig & {
|
||||
@@ -397,9 +313,6 @@ export function createCustomOptions(
|
||||
// Validate working directory before creating options
|
||||
validateWorkingDirectory(config.cwd);
|
||||
|
||||
// Build CLAUDE.md auto-loading options if enabled
|
||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
@@ -407,7 +320,7 @@ export function createCustomOptions(
|
||||
cwd: config.cwd,
|
||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
...claudeMdOptions,
|
||||
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Re-export secure file system utilities from @automaker/platform
|
||||
* This file exists for backward compatibility with existing imports
|
||||
*/
|
||||
|
||||
import { secureFs } from '@automaker/platform';
|
||||
|
||||
export const {
|
||||
access,
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
stat,
|
||||
rm,
|
||||
unlink,
|
||||
copyFile,
|
||||
appendFile,
|
||||
rename,
|
||||
lstat,
|
||||
joinPath,
|
||||
resolvePath,
|
||||
// Throttling configuration and monitoring
|
||||
configureThrottling,
|
||||
getThrottlingConfig,
|
||||
getPendingOperations,
|
||||
getActiveOperations,
|
||||
} = secureFs;
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Helper utilities for loading settings and context file handling across different parts of the server
|
||||
*/
|
||||
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Validation Storage - CRUD operations for GitHub issue validation results
|
||||
*
|
||||
* Stores validation results in .automaker/validations/{issueNumber}/validation.json
|
||||
* Results include the validation verdict, metadata, and timestamp for cache invalidation.
|
||||
*/
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
|
||||
import type { StoredValidation } from '@automaker/types';
|
||||
|
||||
// Re-export StoredValidation for convenience
|
||||
export type { StoredValidation };
|
||||
|
||||
/** Number of hours before a validation is considered stale */
|
||||
const VALIDATION_CACHE_TTL_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Write validation result to storage
|
||||
*
|
||||
* Creates the validation directory if needed and stores the result as JSON.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @param data - Validation data to store
|
||||
*/
|
||||
export async function writeValidation(
|
||||
projectPath: string,
|
||||
issueNumber: number,
|
||||
data: StoredValidation
|
||||
): Promise<void> {
|
||||
const validationDir = getValidationDir(projectPath, issueNumber);
|
||||
const validationPath = getValidationPath(projectPath, issueNumber);
|
||||
|
||||
// Ensure directory exists
|
||||
await secureFs.mkdir(validationDir, { recursive: true });
|
||||
|
||||
// Write validation result
|
||||
await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read validation result from storage
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @returns Stored validation or null if not found
|
||||
*/
|
||||
export async function readValidation(
|
||||
projectPath: string,
|
||||
issueNumber: number
|
||||
): Promise<StoredValidation | null> {
|
||||
try {
|
||||
const validationPath = getValidationPath(projectPath, issueNumber);
|
||||
const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as StoredValidation;
|
||||
} catch {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored validations for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Array of stored validations
|
||||
*/
|
||||
export async function getAllValidations(projectPath: string): Promise<StoredValidation[]> {
|
||||
const validationsDir = getValidationsDir(projectPath);
|
||||
|
||||
try {
|
||||
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
|
||||
|
||||
// Read all validation files in parallel for better performance
|
||||
const promises = dirs
|
||||
.filter((dir) => dir.isDirectory())
|
||||
.map((dir) => {
|
||||
const issueNumber = parseInt(dir.name, 10);
|
||||
if (!isNaN(issueNumber)) {
|
||||
return readValidation(projectPath, issueNumber);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const validations = results.filter((v): v is StoredValidation => v !== null);
|
||||
|
||||
// Sort by issue number
|
||||
validations.sort((a, b) => a.issueNumber - b.issueNumber);
|
||||
|
||||
return validations;
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a validation from storage
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @returns true if validation was deleted, false if not found
|
||||
*/
|
||||
export async function deleteValidation(projectPath: string, issueNumber: number): Promise<boolean> {
|
||||
try {
|
||||
const validationDir = getValidationDir(projectPath, issueNumber);
|
||||
await secureFs.rm(validationDir, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a validation is stale (older than TTL)
|
||||
*
|
||||
* @param validation - Stored validation to check
|
||||
* @returns true if validation is older than 24 hours
|
||||
*/
|
||||
export function isValidationStale(validation: StoredValidation): boolean {
|
||||
const validatedAt = new Date(validation.validatedAt);
|
||||
const now = new Date();
|
||||
const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
|
||||
return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation with freshness info
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @returns Object with validation and isStale flag, or null if not found
|
||||
*/
|
||||
export async function getValidationWithFreshness(
|
||||
projectPath: string,
|
||||
issueNumber: number
|
||||
): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
|
||||
const validation = await readValidation(projectPath, issueNumber);
|
||||
if (!validation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
validation,
|
||||
isStale: isValidationStale(validation),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a validation as viewed by the user
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @returns true if validation was marked as viewed, false if not found
|
||||
*/
|
||||
export async function markValidationViewed(
|
||||
projectPath: string,
|
||||
issueNumber: number
|
||||
): Promise<boolean> {
|
||||
const validation = await readValidation(projectPath, issueNumber);
|
||||
if (!validation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
validation.viewedAt = new Date().toISOString();
|
||||
await writeValidation(projectPath, issueNumber, validation);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of unviewed, non-stale validations for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Number of unviewed validations
|
||||
*/
|
||||
export async function getUnviewedValidationsCount(projectPath: string): Promise<number> {
|
||||
const validations = await getAllValidations(projectPath);
|
||||
return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
|
||||
}
|
||||
@@ -3,26 +3,16 @@
|
||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||
*/
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import * as path from 'path';
|
||||
import type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { WorktreePRInfo, WorktreeMetadata } from '@automaker/types';
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize branch name for cross-platform filesystem safety
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
SimpleQueryOptions,
|
||||
SimpleQueryResult,
|
||||
StreamingQueryOptions,
|
||||
StreamingQueryResult,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,22 @@ export abstract class BaseProvider {
|
||||
*/
|
||||
abstract executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage>;
|
||||
|
||||
/**
|
||||
* Execute a simple one-shot query and return text directly
|
||||
* Use for quick completions without tools (title gen, descriptions, etc.)
|
||||
* @param options Simple query options
|
||||
* @returns Query result with text
|
||||
*/
|
||||
abstract executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult>;
|
||||
|
||||
/**
|
||||
* Execute a streaming query with tools and/or structured output
|
||||
* Use for queries that need tools, progress callbacks, or structured JSON output
|
||||
* @param options Streaming query options
|
||||
* @returns Query result with text and optional structured output
|
||||
*/
|
||||
abstract executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult>;
|
||||
|
||||
/**
|
||||
* Detect if the provider is installed and configured
|
||||
* @returns Installation status
|
||||
|
||||
@@ -3,15 +3,26 @@
|
||||
*
|
||||
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
|
||||
* with the provider architecture.
|
||||
*
|
||||
* Provides two query methods:
|
||||
* - executeQuery(): Streaming async generator for complex multi-turn sessions
|
||||
* - executeSimpleQuery(): One-shot queries that return text directly (title gen, descriptions, etc.)
|
||||
*/
|
||||
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
SimpleQueryOptions,
|
||||
SimpleQueryResult,
|
||||
StreamingQueryOptions,
|
||||
StreamingQueryResult,
|
||||
PromptContentBlock,
|
||||
} from './types.js';
|
||||
|
||||
export class ClaudeProvider extends BaseProvider {
|
||||
@@ -45,16 +56,16 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: toolsToUse,
|
||||
permissionMode: 'default',
|
||||
permissionMode: 'acceptEdits',
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
? { resume: sdkSessionId }
|
||||
: {}),
|
||||
// Forward settingSources for CLAUDE.md file loading
|
||||
...(options.settingSources && { settingSources: options.settingSources }),
|
||||
// Forward sandbox configuration
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
@@ -88,8 +99,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
yield msg as ProviderMessage;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
|
||||
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
|
||||
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -176,4 +186,225 @@ export class ClaudeProvider extends BaseProvider {
|
||||
const supportedFeatures = ['tools', 'text', 'vision', 'thinking'];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a simple one-shot query and return text directly
|
||||
*
|
||||
* Use this for:
|
||||
* - Title generation from description
|
||||
* - Text enhancement
|
||||
* - File/image description
|
||||
* - Any quick, single-turn completion without tools
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
* const result = await provider.executeSimpleQuery({
|
||||
* prompt: 'Generate a title for: User authentication feature',
|
||||
* systemPrompt: 'You are a title generator...',
|
||||
* });
|
||||
* if (result.success) console.log(result.text);
|
||||
* ```
|
||||
*/
|
||||
async executeSimpleQuery(options: SimpleQueryOptions): Promise<SimpleQueryResult> {
|
||||
const { prompt, model, systemPrompt, abortController } = options;
|
||||
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
|
||||
|
||||
try {
|
||||
const sdkOptions: Options = {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Handle both string prompts and multi-part content blocks
|
||||
const stream = Array.isArray(prompt)
|
||||
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
|
||||
: query({ prompt, options: sdkOptions });
|
||||
const { text } = await this.extractTextFromStream(stream);
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
text: '',
|
||||
success: false,
|
||||
error: 'Empty response from Claude',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ClaudeProvider] executeSimpleQuery() error:', errorMessage);
|
||||
return {
|
||||
text: '',
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a streaming query with tools and/or structured output
|
||||
*
|
||||
* Use this for:
|
||||
* - Spec generation (with JSON schema output)
|
||||
* - Feature generation from specs
|
||||
* - Suggestions generation
|
||||
* - Any query that needs tools or progress callbacks
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = ProviderFactory.getProviderForModel('opus');
|
||||
* const result = await provider.executeStreamingQuery({
|
||||
* prompt: 'Analyze this project...',
|
||||
* cwd: '/path/to/project',
|
||||
* allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
* outputFormat: { type: 'json_schema', schema: mySchema },
|
||||
* onText: (chunk) => console.log('Progress:', chunk),
|
||||
* });
|
||||
* console.log(result.structuredOutput);
|
||||
* ```
|
||||
*/
|
||||
async executeStreamingQuery(options: StreamingQueryOptions): Promise<StreamingQueryResult> {
|
||||
const {
|
||||
prompt,
|
||||
model,
|
||||
systemPrompt,
|
||||
cwd,
|
||||
maxTurns = 100,
|
||||
allowedTools = ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
outputFormat,
|
||||
onText,
|
||||
onToolUse,
|
||||
} = options;
|
||||
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.haiku);
|
||||
|
||||
try {
|
||||
const sdkOptions: Options = {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
allowedTools: [...allowedTools],
|
||||
permissionMode: 'acceptEdits',
|
||||
abortController,
|
||||
...(outputFormat && { outputFormat }),
|
||||
};
|
||||
|
||||
// Handle both string prompts and multi-part content blocks
|
||||
const stream = Array.isArray(prompt)
|
||||
? query({ prompt: this.createPromptGenerator(prompt), options: sdkOptions })
|
||||
: query({ prompt, options: sdkOptions });
|
||||
const { text, structuredOutput } = await this.extractTextFromStream(stream, {
|
||||
onText,
|
||||
onToolUse,
|
||||
});
|
||||
|
||||
if (!text && !structuredOutput) {
|
||||
return {
|
||||
text: '',
|
||||
success: false,
|
||||
error: 'Empty response from Claude',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
success: true,
|
||||
structuredOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ClaudeProvider] executeStreamingQuery() error:', errorMessage);
|
||||
return {
|
||||
text: '',
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a multi-part prompt generator for content blocks
|
||||
*/
|
||||
private createPromptGenerator(content: PromptContentBlock[]) {
|
||||
// Return an async generator that yields SDK user messages
|
||||
// The SDK expects this format for multi-part prompts
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text and structured output from SDK stream
|
||||
*
|
||||
* This consolidates the duplicated extractTextFromStream() function
|
||||
* that was copied across 5+ route files.
|
||||
*/
|
||||
private async extractTextFromStream(
|
||||
stream: AsyncIterable<unknown>,
|
||||
handlers?: {
|
||||
onText?: (text: string) => void;
|
||||
onToolUse?: (name: string, input: unknown) => void;
|
||||
}
|
||||
): Promise<{ text: string; structuredOutput?: unknown }> {
|
||||
let responseText = '';
|
||||
let structuredOutput: unknown = undefined;
|
||||
|
||||
for await (const msg of stream) {
|
||||
const message = msg as {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
structured_output?: unknown;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string; name?: string; input?: unknown }>;
|
||||
};
|
||||
};
|
||||
|
||||
if (message.type === 'assistant' && message.message?.content) {
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
handlers?.onText?.(block.text);
|
||||
} else if (block.type === 'tool_use' && block.name) {
|
||||
handlers?.onToolUse?.(block.name, block.input);
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'result' && message.subtype === 'success') {
|
||||
if (message.result) {
|
||||
responseText = message.result;
|
||||
}
|
||||
if (message.structured_output) {
|
||||
structuredOutput = message.structured_output;
|
||||
}
|
||||
} else if (message.type === 'result' && message.subtype === 'error_max_turns') {
|
||||
console.warn('[ClaudeProvider] Hit max turns limit');
|
||||
} else if (
|
||||
message.type === 'result' &&
|
||||
message.subtype === 'error_max_structured_output_retries'
|
||||
) {
|
||||
throw new Error('Failed to produce valid structured output after retries');
|
||||
} else if (message.type === 'error') {
|
||||
const errorMsg = (message as { error?: string }).error || 'Unknown error';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: responseText, structuredOutput };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,13 @@ export interface ExecuteOptions {
|
||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
||||
model: string;
|
||||
cwd: string;
|
||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: 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
|
||||
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,3 +102,92 @@ export interface ModelDefinition {
|
||||
tier?: 'basic' | 'standard' | 'premium';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block for multi-part prompts (images, structured text)
|
||||
*/
|
||||
export interface TextContentBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageContentBlock {
|
||||
type: 'image';
|
||||
source: {
|
||||
type: 'base64';
|
||||
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PromptContentBlock = TextContentBlock | ImageContentBlock;
|
||||
|
||||
/**
|
||||
* Options for simple one-shot queries (title generation, descriptions, text enhancement)
|
||||
*
|
||||
* These queries:
|
||||
* - Don't need tools
|
||||
* - Return text directly (no streaming)
|
||||
* - Are single-turn (maxTurns=1)
|
||||
*/
|
||||
export interface SimpleQueryOptions {
|
||||
/** The prompt - either a string or array of content blocks */
|
||||
prompt: string | PromptContentBlock[];
|
||||
|
||||
/** Model to use (defaults to haiku) */
|
||||
model?: string;
|
||||
|
||||
/** Optional system prompt */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** Abort controller for cancellation */
|
||||
abortController?: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a simple query
|
||||
*/
|
||||
export interface SimpleQueryResult {
|
||||
/** Extracted text from the response */
|
||||
text: string;
|
||||
|
||||
/** Whether the query completed successfully */
|
||||
success: boolean;
|
||||
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for streaming queries with tools and/or structured output
|
||||
*/
|
||||
export interface StreamingQueryOptions extends SimpleQueryOptions {
|
||||
/** Working directory for tool execution */
|
||||
cwd: string;
|
||||
|
||||
/** Max turns (defaults to sdk-options presets) */
|
||||
maxTurns?: number;
|
||||
|
||||
/** Tools to allow */
|
||||
allowedTools?: readonly string[];
|
||||
|
||||
/** JSON schema for structured output */
|
||||
outputFormat?: {
|
||||
type: 'json_schema';
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Callback for text chunks */
|
||||
onText?: (text: string) => void;
|
||||
|
||||
/** Callback for tool usage */
|
||||
onToolUse?: (name: string, input: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from a streaming query with structured output
|
||||
*/
|
||||
export interface StreamingQueryResult extends SimpleQueryResult {
|
||||
/** Parsed structured output if outputFormat was specified */
|
||||
structuredOutput?: unknown;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ import { createHistoryHandler } from './routes/history.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createClearHandler } from './routes/clear.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 {
|
||||
const router = Router();
|
||||
@@ -31,15 +27,5 @@ export function createAgentRoutes(agentService: AgentService, _events: EventEmit
|
||||
router.post('/clear', createClearHandler(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;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* POST /queue/add endpoint - Add a prompt to the queue
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createQueueAddHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, imagePaths, model } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId and message are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.addToQueue(sessionId, { message, imagePaths, model });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Add to queue failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* POST /queue/clear endpoint - Clear all prompts from the queue
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createQueueClearHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.clearQueue(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Clear queue failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* POST /queue/list endpoint - List queued prompts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createQueueListHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getQueue(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'List queue failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* POST /queue/remove endpoint - Remove a prompt from the queue
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createQueueRemoveHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, promptId } = req.body as {
|
||||
sessionId: string;
|
||||
promptId: string;
|
||||
};
|
||||
|
||||
if (!sessionId || !promptId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId and promptId are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.removeFromQueue(sessionId, promptId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Remove from queue failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -19,16 +19,7 @@ export function createSendHandler(agentService: AgentService) {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
console.log('[Send Handler] Received request:', {
|
||||
sessionId,
|
||||
messageLength: message?.length,
|
||||
workingDirectory,
|
||||
imageCount: imagePaths?.length || 0,
|
||||
model,
|
||||
});
|
||||
|
||||
if (!sessionId || !message) {
|
||||
console.log('[Send Handler] ERROR: Validation failed - missing sessionId or message');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId and message are required',
|
||||
@@ -36,8 +27,6 @@ export function createSendHandler(agentService: AgentService) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Send Handler] Validation passed, calling agentService.sendMessage()');
|
||||
|
||||
// Start the message processing (don't await - it streams via WebSocket)
|
||||
agentService
|
||||
.sendMessage({
|
||||
@@ -48,16 +37,12 @@ export function createSendHandler(agentService: AgentService) {
|
||||
model,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Send Handler] ERROR: Background error in sendMessage():', error);
|
||||
logError(error, 'Send message failed (background)');
|
||||
});
|
||||
|
||||
console.log('[Send Handler] Returning immediate response to client');
|
||||
|
||||
// Return immediately - responses come via WebSocket
|
||||
res.json({ success: true, message: 'Message sent' });
|
||||
} catch (error) {
|
||||
console.error('[Send Handler] ERROR: Synchronous error:', error);
|
||||
logError(error, 'Send message failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
/**
|
||||
* Generate features from existing app_spec.txt
|
||||
*
|
||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import { getAppSpecPath, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -21,8 +18,7 @@ export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
maxFeatures?: number,
|
||||
settingsService?: SettingsService
|
||||
maxFeatures?: number
|
||||
): Promise<void> {
|
||||
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
|
||||
logger.debug('========== generateFeaturesFromSpec() started ==========');
|
||||
@@ -94,80 +90,37 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
projectPath: projectPath,
|
||||
});
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
logger.info('Calling provider.executeStreamingQuery() for features...');
|
||||
|
||||
const options = createFeatureGenerationOptions({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
cwd: projectPath,
|
||||
maxTurns: 50,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query() for features...');
|
||||
|
||||
logAuthStatus('Right before SDK query() for features');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
if (!result.success) {
|
||||
logger.error('❌ Feature generation failed:', result.error);
|
||||
throw new Error(result.error || 'Feature generation failed');
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
|
||||
logger.debug('Starting to iterate over feature stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.debug(
|
||||
`Feature stream message #${messageCount}:`,
|
||||
JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2)
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.debug(`Feature text block received (${block.text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.debug('Received success result for features');
|
||||
responseText = (msg as any).result || responseText;
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from feature stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating feature stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info(`Feature response length: ${result.text.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info(result.text);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
await parseAndCreateFeatures(projectPath, result.text, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Generate app_spec.txt from project overview
|
||||
*
|
||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import {
|
||||
specOutputSchema,
|
||||
@@ -13,12 +12,9 @@ import {
|
||||
type SpecOutput,
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { logAuthStatus } from './common.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -29,8 +25,7 @@ export async function generateSpec(
|
||||
abortController: AbortController,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number,
|
||||
settingsService?: SettingsService
|
||||
maxFeatures?: number
|
||||
): Promise<void> {
|
||||
logger.info('========== generateSpec() started ==========');
|
||||
logger.info('projectPath:', projectPath);
|
||||
@@ -86,113 +81,53 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
content: 'Starting spec generation...\n',
|
||||
});
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
logger.info('Calling provider.executeStreamingQuery()...');
|
||||
|
||||
const options = createSpecGenerationOptions({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
cwd: projectPath,
|
||||
maxTurns: 1000,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: specOutputSchema,
|
||||
},
|
||||
onText: (text) => {
|
||||
logger.info(`Text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
},
|
||||
onToolUse: (name, input) => {
|
||||
logger.info('Tool use:', name);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_tool',
|
||||
tool: name,
|
||||
input,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('SDK Options:', JSON.stringify(options, null, 2));
|
||||
logger.info('Calling Claude Agent SDK query()...');
|
||||
|
||||
// Log auth status right before the SDK call
|
||||
logAuthStatus('Right before SDK query()');
|
||||
|
||||
let stream;
|
||||
try {
|
||||
stream = query({ prompt, options });
|
||||
logger.debug('query() returned stream successfully');
|
||||
} catch (queryError) {
|
||||
logger.error('❌ query() threw an exception:');
|
||||
logger.error('Error:', queryError);
|
||||
throw queryError;
|
||||
if (!result.success) {
|
||||
logger.error('❌ Spec generation failed:', result.error);
|
||||
throw new Error(result.error || 'Spec generation failed');
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
const responseText = result.text;
|
||||
const structuredOutput = result.structuredOutput as SpecOutput | undefined;
|
||||
|
||||
logger.info('Starting to iterate over stream...');
|
||||
|
||||
try {
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
logger.info(
|
||||
`Stream message #${messageCount}: type=${msg.type}, subtype=${(msg as any).subtype}`
|
||||
);
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
const msgAny = msg as any;
|
||||
if (msgAny.message?.content) {
|
||||
for (const block of msgAny.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
logger.info(
|
||||
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
|
||||
);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_progress',
|
||||
content: block.text,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
logger.info('Tool use:', block.name);
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && (msg as any).subtype === 'success') {
|
||||
logger.info('Received success result');
|
||||
// Check for structured output - this is the reliable way to get spec data
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as SpecOutput;
|
||||
logger.info('✅ Received structured output');
|
||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
// Handle error result types
|
||||
const subtype = (msg as any).subtype;
|
||||
logger.info(`Result message: subtype=${subtype}`);
|
||||
if (subtype === 'error_max_turns') {
|
||||
logger.error('❌ Hit max turns limit!');
|
||||
} else if (subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('❌ Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid spec output');
|
||||
}
|
||||
} else if ((msg as { type: string }).type === 'error') {
|
||||
logger.error('❌ Received error message from stream:');
|
||||
logger.error('Error message:', JSON.stringify(msg, null, 2));
|
||||
} else if (msg.type === 'user') {
|
||||
// Log user messages (tool results)
|
||||
logger.info(`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`);
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
logger.error('❌ Error while iterating stream:');
|
||||
logger.error('Stream error:', streamError);
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
|
||||
logger.info(`Response text length: ${responseText.length} chars`);
|
||||
if (structuredOutput) {
|
||||
logger.info('✅ Received structured output');
|
||||
logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2));
|
||||
} else {
|
||||
logger.warn('⚠️ No structured output in result, will fall back to text parsing');
|
||||
}
|
||||
|
||||
// Determine XML content to save
|
||||
let xmlContent: string;
|
||||
@@ -280,13 +215,7 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
// Create a new abort controller for feature generation
|
||||
const featureAbortController = new AbortController();
|
||||
try {
|
||||
await generateFeaturesFromSpec(
|
||||
projectPath,
|
||||
events,
|
||||
featureAbortController,
|
||||
maxFeatures,
|
||||
settingsService
|
||||
);
|
||||
await generateFeaturesFromSpec(projectPath, events, featureAbortController, maxFeatures);
|
||||
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
|
||||
} catch (featureError) {
|
||||
logger.error('Feature generation failed:', featureError);
|
||||
|
||||
@@ -9,17 +9,13 @@ import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createGenerateFeaturesHandler } from './routes/generate-features.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createSpecRegenerationRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/create', createCreateHandler(events));
|
||||
router.post('/generate', createGenerateHandler(events, settingsService));
|
||||
router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService));
|
||||
router.post('/generate', createGenerateHandler(events));
|
||||
router.post('/generate-features', createGenerateFeaturesHandler(events));
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { getFeaturesDir, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
|
||||
@@ -13,14 +13,10 @@ import {
|
||||
getErrorMessage,
|
||||
} from '../common.js';
|
||||
import { generateFeaturesFromSpec } from '../generate-features-from-spec.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export function createGenerateFeaturesHandler(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
) {
|
||||
export function createGenerateFeaturesHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /generate-features endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
@@ -53,7 +49,7 @@ export function createGenerateFeaturesHandler(
|
||||
setRunningState(true, abortController);
|
||||
logger.info('Starting background feature generation task...');
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
|
||||
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures)
|
||||
.catch((error) => {
|
||||
logError(error, 'Feature generation failed with error');
|
||||
events.emit('spec-regeneration:event', {
|
||||
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
getErrorMessage,
|
||||
} from '../common.js';
|
||||
import { generateSpec } from '../generate-spec.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
export function createGenerateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /generate endpoint called ==========');
|
||||
logger.debug('Request body:', JSON.stringify(req.body, null, 2));
|
||||
@@ -68,8 +67,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
abortController,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
maxFeatures,
|
||||
settingsService
|
||||
maxFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generation failed with error');
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Common utilities for backlog plan routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('BacklogPlan');
|
||||
|
||||
// State for tracking running generation
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
export function getBacklogPlanStatus(): { isRunning: boolean } {
|
||||
return { isRunning };
|
||||
}
|
||||
|
||||
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
|
||||
isRunning = running;
|
||||
if (abortController !== undefined) {
|
||||
currentAbortController = abortController;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAbortController(): AbortController | null {
|
||||
return currentAbortController;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error));
|
||||
}
|
||||
|
||||
export { logger };
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Generate backlog plan using Claude AI
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { logger, setRunningState, getErrorMessage } from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
/**
|
||||
* Format features for the AI prompt
|
||||
*/
|
||||
function formatFeaturesForPrompt(features: Feature[]): string {
|
||||
if (features.length === 0) {
|
||||
return 'No features in backlog yet.';
|
||||
}
|
||||
|
||||
return features
|
||||
.map((f) => {
|
||||
const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : '';
|
||||
const priority = f.priority !== undefined ? `Priority: ${f.priority}` : '';
|
||||
return `- ID: ${f.id}
|
||||
Title: ${f.title || 'Untitled'}
|
||||
Description: ${f.description}
|
||||
Category: ${f.category}
|
||||
Status: ${f.status || 'backlog'}
|
||||
${priority}
|
||||
${deps}`.trim();
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the AI response into a BacklogPlanResult
|
||||
*/
|
||||
function parsePlanResponse(response: string): BacklogPlanResult {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[1]);
|
||||
}
|
||||
|
||||
// Try to parse the whole response as JSON
|
||||
return JSON.parse(response);
|
||||
} catch {
|
||||
// If parsing fails, return an empty result
|
||||
logger.warn('[BacklogPlan] Failed to parse AI response as JSON');
|
||||
return {
|
||||
changes: [],
|
||||
summary: 'Failed to parse AI response',
|
||||
dependencyUpdates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a backlog modification plan based on user prompt
|
||||
*/
|
||||
export async function generateBacklogPlan(
|
||||
projectPath: string,
|
||||
prompt: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
model?: string
|
||||
): Promise<BacklogPlanResult> {
|
||||
try {
|
||||
// Load current features
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
content: `Loaded ${features.length} features from backlog`,
|
||||
});
|
||||
|
||||
// Build the system prompt
|
||||
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
|
||||
You will be given the current list of features and a user request to modify the backlog.
|
||||
|
||||
IMPORTANT CONTEXT (automatically injected):
|
||||
- Remember to update the dependency graph if deleting existing features
|
||||
- Remember to define dependencies on new features hooked into relevant existing ones
|
||||
- Maintain dependency graph integrity (no orphaned dependencies)
|
||||
- When deleting a feature, identify which other features depend on it
|
||||
|
||||
Your task is to analyze the request and produce a structured JSON plan with:
|
||||
1. Features to ADD (include title, description, category, and dependencies)
|
||||
2. Features to UPDATE (specify featureId and the updates)
|
||||
3. Features to DELETE (specify featureId)
|
||||
4. A summary of the changes
|
||||
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
|
||||
|
||||
Respond with ONLY a JSON object in this exact format:
|
||||
\`\`\`json
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"type": "add",
|
||||
"feature": {
|
||||
"title": "Feature title",
|
||||
"description": "Feature description",
|
||||
"category": "Category name",
|
||||
"dependencies": ["existing-feature-id"],
|
||||
"priority": 1
|
||||
},
|
||||
"reason": "Why this feature should be added"
|
||||
},
|
||||
{
|
||||
"type": "update",
|
||||
"featureId": "existing-feature-id",
|
||||
"feature": {
|
||||
"title": "Updated title"
|
||||
},
|
||||
"reason": "Why this feature should be updated"
|
||||
},
|
||||
{
|
||||
"type": "delete",
|
||||
"featureId": "feature-id-to-delete",
|
||||
"reason": "Why this feature should be deleted"
|
||||
}
|
||||
],
|
||||
"summary": "Brief overview of all proposed changes",
|
||||
"dependencyUpdates": [
|
||||
{
|
||||
"featureId": "feature-that-depended-on-deleted",
|
||||
"removedDependencies": ["deleted-feature-id"],
|
||||
"addedDependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
// Build the user prompt
|
||||
const userPrompt = `Current Features in Backlog:
|
||||
${formatFeaturesForPrompt(features)}
|
||||
|
||||
---
|
||||
|
||||
User Request: ${prompt}
|
||||
|
||||
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
content: 'Generating plan with AI...',
|
||||
});
|
||||
|
||||
// Get the model to use
|
||||
const effectiveModel = model || 'sonnet';
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
prompt: userPrompt,
|
||||
model: effectiveModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [], // No tools needed for this
|
||||
abortController,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Generation aborted');
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_complete',
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
logger.error('[BacklogPlan] Generation failed:', errorMessage);
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_error',
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setRunningState(false, null);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Backlog Plan routes - HTTP API for AI-assisted backlog modification
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createApplyHandler } from './routes/apply.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createBacklogPlanRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* POST /apply endpoint - Apply a backlog plan
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
export function createApplyHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, plan } = req.body as {
|
||||
projectPath: string;
|
||||
plan: BacklogPlanResult;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!plan || !plan.changes) {
|
||||
res.status(400).json({ success: false, error: 'plan with changes required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedChanges: string[] = [];
|
||||
|
||||
// Load current features for dependency validation
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const featureMap = new Map(allFeatures.map((f) => [f.id, f]));
|
||||
|
||||
// Process changes in order: deletes first, then adds, then updates
|
||||
// This ensures we can remove dependencies before they cause issues
|
||||
|
||||
// 1. First pass: Handle deletes
|
||||
const deletions = plan.changes.filter((c) => c.type === 'delete');
|
||||
for (const change of deletions) {
|
||||
if (!change.featureId) continue;
|
||||
|
||||
try {
|
||||
// Before deleting, update any features that depend on this one
|
||||
for (const feature of allFeatures) {
|
||||
if (feature.dependencies?.includes(change.featureId)) {
|
||||
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
|
||||
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
|
||||
logger.info(
|
||||
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Now delete the feature
|
||||
const deleted = await featureLoader.delete(projectPath, change.featureId);
|
||||
if (deleted) {
|
||||
appliedChanges.push(`deleted:${change.featureId}`);
|
||||
featureMap.delete(change.featureId);
|
||||
logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[BacklogPlan] Failed to delete ${change.featureId}:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Second pass: Handle adds
|
||||
const additions = plan.changes.filter((c) => c.type === 'add');
|
||||
for (const change of additions) {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
// Create the new feature
|
||||
const newFeature = await featureLoader.create(projectPath, {
|
||||
title: change.feature.title,
|
||||
description: change.feature.description || '',
|
||||
category: change.feature.category || 'Uncategorized',
|
||||
dependencies: change.feature.dependencies,
|
||||
priority: change.feature.priority,
|
||||
status: 'backlog',
|
||||
});
|
||||
|
||||
appliedChanges.push(`added:${newFeature.id}`);
|
||||
featureMap.set(newFeature.id, newFeature);
|
||||
logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`);
|
||||
} catch (error) {
|
||||
logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Third pass: Handle updates
|
||||
const updates = plan.changes.filter((c) => c.type === 'update');
|
||||
for (const change of updates) {
|
||||
if (!change.featureId || !change.feature) continue;
|
||||
|
||||
try {
|
||||
const updated = await featureLoader.update(projectPath, change.featureId, change.feature);
|
||||
appliedChanges.push(`updated:${change.featureId}`);
|
||||
featureMap.set(change.featureId, updated);
|
||||
logger.info(`[BacklogPlan] Updated feature ${change.featureId}`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[BacklogPlan] Failed to update ${change.featureId}:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Apply dependency updates from the plan
|
||||
if (plan.dependencyUpdates) {
|
||||
for (const depUpdate of plan.dependencyUpdates) {
|
||||
try {
|
||||
const feature = featureMap.get(depUpdate.featureId);
|
||||
if (feature) {
|
||||
const currentDeps = feature.dependencies || [];
|
||||
const newDeps = currentDeps
|
||||
.filter((d) => !depUpdate.removedDependencies.includes(d))
|
||||
.concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d)));
|
||||
|
||||
await featureLoader.update(projectPath, depUpdate.featureId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
appliedChanges,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Apply backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* POST /generate endpoint - Generate a backlog plan
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import { generateBacklogPlan } from '../generate-plan.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, prompt, model } = req.body as {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
res.status(400).json({ success: false, error: 'prompt required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getBacklogPlanStatus();
|
||||
if (isRunning) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: 'Backlog plan generation is already running',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Generate backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* GET /status endpoint - Get backlog plan generation status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const status = getBacklogPlanStatus();
|
||||
res.json({ success: true, ...status });
|
||||
} catch (error) {
|
||||
logError(error, 'Get backlog plan status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop the current backlog plan generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const abortController = getAbortController();
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setRunningState(false, null);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,35 +1,6 @@
|
||||
/**
|
||||
* Claude Usage types for CLI-based usage tracking
|
||||
* Re-exported from @automaker/types for convenience
|
||||
*/
|
||||
|
||||
export type ClaudeUsage = {
|
||||
sessionTokensUsed: number;
|
||||
sessionLimit: number;
|
||||
sessionPercentage: number;
|
||||
sessionResetTime: string; // ISO date string
|
||||
sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)"
|
||||
|
||||
weeklyTokensUsed: number;
|
||||
weeklyLimit: number;
|
||||
weeklyPercentage: number;
|
||||
weeklyResetTime: string; // ISO date string
|
||||
weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)"
|
||||
|
||||
sonnetWeeklyTokensUsed: number;
|
||||
sonnetWeeklyPercentage: number;
|
||||
sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)"
|
||||
|
||||
costUsed: number | null;
|
||||
costLimit: number | null;
|
||||
costCurrency: string | null;
|
||||
|
||||
lastUpdated: string; // ISO date string
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
export type ClaudeStatus = {
|
||||
indicator: {
|
||||
color: 'green' | 'yellow' | 'orange' | 'red' | 'gray';
|
||||
};
|
||||
description: string;
|
||||
};
|
||||
export type { ClaudeUsage, ClaudeStatus } from '@automaker/types';
|
||||
|
||||
@@ -18,14 +18,13 @@ export {
|
||||
getGitRepositoryDiffs,
|
||||
} from '@automaker/git-utils';
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
// Re-export error utilities from shared package
|
||||
export { getErrorMessage } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Get error message from error object
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
// Re-export exec utilities
|
||||
export { execAsync, execEnv, isENOENT } from '../lib/exec-utils.js';
|
||||
|
||||
type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
/**
|
||||
* Create a logError function for a specific logger
|
||||
|
||||
@@ -8,19 +8,17 @@
|
||||
import { Router } from 'express';
|
||||
import { createDescribeImageHandler } from './routes/describe-image.js';
|
||||
import { createDescribeFileHandler } from './routes/describe-file.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
/**
|
||||
* Create the context router
|
||||
*
|
||||
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
|
||||
* @returns Express router with context endpoints
|
||||
*/
|
||||
export function createContextRoutes(settingsService?: SettingsService): Router {
|
||||
export function createContextRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/describe-image', createDescribeImageHandler(settingsService));
|
||||
router.post('/describe-file', createDescribeFileHandler(settingsService));
|
||||
router.post('/describe-image', createDescribeImageHandler());
|
||||
router.post('/describe-file', createDescribeFileHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* POST /context/describe-file endpoint - Generate description for a text file
|
||||
*
|
||||
* Uses Claude Haiku to analyze a text file and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses Claude Haiku via ClaudeProvider to analyze a text file and generate
|
||||
* a concise description suitable for context file metadata.
|
||||
*
|
||||
* SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY
|
||||
* and reads file content directly (not via Claude's Read tool) to prevent
|
||||
@@ -10,15 +10,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError, secureFs } from '@automaker/platform';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
|
||||
@@ -46,40 +41,12 @@ interface DescribeFileErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stream: AsyncIterable<any>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the describe-file request handler
|
||||
*
|
||||
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
|
||||
* @returns Express request handler for file description
|
||||
*/
|
||||
export function createDescribeFileHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
export function createDescribeFileHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { filePath } = req.body as DescribeFileRequestBody;
|
||||
@@ -155,68 +122,39 @@ export function createDescribeFileHandler(
|
||||
const fileName = path.basename(resolvedPath);
|
||||
|
||||
// Build prompt with file content passed as structured data
|
||||
// The file content is included directly, not via tool invocation
|
||||
const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||
const promptContent = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project").
|
||||
|
||||
Respond with ONLY the description text, no additional formatting, preamble, or explanation.
|
||||
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
|
||||
const promptContent = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
File: ${fileName}${truncated ? ' (truncated)' : ''}`,
|
||||
},
|
||||
{ type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` },
|
||||
];
|
||||
|
||||
// Use the file's directory as the working directory
|
||||
const cwd = path.dirname(resolvedPath);
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
cwd,
|
||||
settingsService,
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Use centralized SDK options with proper cwd validation
|
||||
// No tools needed since we're passing file content directly
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: promptContent,
|
||||
model: 'haiku',
|
||||
});
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
|
||||
// Extract the description from the response
|
||||
const description = await extractTextFromStream(stream);
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to generate description:', result.error);
|
||||
const response: DescribeFileErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
error: result.error || 'Failed to generate description',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Description generated, length: ${description.length} chars`);
|
||||
logger.info(`Description generated, length: ${result.text.length} chars`);
|
||||
|
||||
const response: DescribeFileSuccessResponse = {
|
||||
success: true,
|
||||
description: description.trim(),
|
||||
description: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* POST /context/describe-image endpoint - Generate description for an image
|
||||
*
|
||||
* Uses Claude Haiku to analyze an image and generate a concise description
|
||||
* suitable for context file metadata.
|
||||
* Uses Claude Haiku via ClaudeProvider to analyze an image and generate
|
||||
* a concise description suitable for context file metadata.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks),
|
||||
@@ -11,14 +11,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import type { PromptContentBlock } from '../../../providers/types.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
|
||||
@@ -175,65 +172,15 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages and log high-signal stream events.
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stream: AsyncIterable<any>,
|
||||
requestId: string
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
let messageCount = 0;
|
||||
|
||||
logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`);
|
||||
|
||||
for await (const msg of stream) {
|
||||
messageCount++;
|
||||
const msgType = msg?.type;
|
||||
const msgSubtype = msg?.subtype;
|
||||
|
||||
// Keep this concise but informative. Full error object is logged in catch blocks.
|
||||
logger.info(
|
||||
`[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}`
|
||||
);
|
||||
|
||||
if (msgType === 'assistant' && msg.message?.content) {
|
||||
const blocks = msg.message.content as Array<{ type: string; text?: string }>;
|
||||
logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`);
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === 'result' && msgSubtype === 'success') {
|
||||
if (typeof msg.result === 'string' && msg.result.length > 0) {
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}`
|
||||
);
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the describe-image request handler
|
||||
*
|
||||
* Uses Claude SDK query with multi-part content blocks to include the image (base64),
|
||||
* matching the agent runner behavior.
|
||||
*
|
||||
* @param settingsService - Optional settings service for loading autoLoadClaudeMd setting
|
||||
* @returns Express request handler for image description
|
||||
*/
|
||||
export function createDescribeImageHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
export function createDescribeImageHandler(): (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 startedAt = Date.now();
|
||||
@@ -313,13 +260,17 @@ export function createDescribeImageHandler(
|
||||
`"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` +
|
||||
`Respond with ONLY the description text, no additional formatting, preamble, or explanation.`;
|
||||
|
||||
const promptContent = [
|
||||
{ type: 'text' as const, text: instructionText },
|
||||
const promptContent: PromptContentBlock[] = [
|
||||
{ type: 'text', text: instructionText },
|
||||
{
|
||||
type: 'image' as const,
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: imageData.mimeType,
|
||||
type: 'base64',
|
||||
media_type: imageData.mimeType as
|
||||
| 'image/jpeg'
|
||||
| 'image/png'
|
||||
| 'image/gif'
|
||||
| 'image/webp',
|
||||
data: imageData.base64,
|
||||
},
|
||||
},
|
||||
@@ -327,56 +278,26 @@ export function createDescribeImageHandler(
|
||||
|
||||
logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`);
|
||||
|
||||
const cwd = path.dirname(actualPath);
|
||||
logger.info(`[${requestId}] Using cwd=${cwd}`);
|
||||
logger.info(`[${requestId}] Calling provider.executeSimpleQuery()...`);
|
||||
const queryStart = Date.now();
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
cwd,
|
||||
settingsService,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Use the same centralized option builder used across the server (validates cwd)
|
||||
const sdkOptions = createCustomOptions({
|
||||
cwd,
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: promptContent,
|
||||
model: 'haiku',
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
logger.info(`[${requestId}] Query completed in ${Date.now() - queryStart}ms`);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
message: { role: 'user' as const, content: promptContent },
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})();
|
||||
const description = result.success ? result.text : '';
|
||||
|
||||
logger.info(`[${requestId}] Calling query()...`);
|
||||
const queryStart = Date.now();
|
||||
const stream = query({ prompt: promptGenerator, options: sdkOptions });
|
||||
logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`);
|
||||
|
||||
// Extract the description from the response
|
||||
const extractStart = Date.now();
|
||||
const description = await extractTextFromStream(stream, requestId);
|
||||
logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`);
|
||||
|
||||
if (!description || description.trim().length === 0) {
|
||||
logger.warn(`[${requestId}] Received empty response from Claude`);
|
||||
if (!result.success || !description || description.trim().length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to generate description: ${result.error || 'empty response'}`
|
||||
);
|
||||
const response: DescribeImageErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate description - empty response',
|
||||
error: result.error || 'Failed to generate description - empty response',
|
||||
requestId,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* POST /enhance-prompt endpoint - Enhance user input text
|
||||
*
|
||||
* Uses Claude AI to enhance text based on the specified enhancement mode.
|
||||
* Supports modes: improve, technical, simplify, acceptance
|
||||
* Uses Claude AI via ClaudeProvider to enhance text based on the specified
|
||||
* enhancement mode. Supports modes: improve, technical, simplify, acceptance
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/types';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import {
|
||||
getSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
} from '@automaker/prompts';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -47,39 +45,6 @@ interface EnhanceErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from Claude SDK response messages
|
||||
*
|
||||
* @param stream - The async iterable from the query function
|
||||
* @returns The extracted text content
|
||||
*/
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -132,45 +97,30 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
|
||||
const systemPrompt = getSystemPrompt(validMode);
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
// This helps the model understand this is text transformation, not a coding task
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
|
||||
// Resolve the model - use the passed model, default to sonnet for quality
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
// Call Claude SDK with minimal configuration for text transformation
|
||||
// Key: no tools, just text completion
|
||||
const stream = query({
|
||||
const provider = ProviderFactory.getProviderForModel(model || 'sonnet');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: resolvedModel,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
model: model || 'sonnet',
|
||||
systemPrompt,
|
||||
});
|
||||
|
||||
// Extract the enhanced text from the response
|
||||
const enhancedText = await extractTextFromStream(stream);
|
||||
|
||||
if (!enhancedText || enhancedText.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to enhance text:', result.error);
|
||||
const response: EnhanceErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate enhanced text - empty response',
|
||||
error: result.error || 'Failed to generate enhanced text',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`);
|
||||
logger.info(`Enhancement complete, output length: ${result.text.length} chars`);
|
||||
|
||||
const response: EnhanceSuccessResponse = {
|
||||
success: true,
|
||||
enhancedText: enhancedText.trim(),
|
||||
enhancedText: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||
*
|
||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||
* Uses Claude Haiku via ClaudeProvider to generate a short, descriptive title.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
@@ -34,33 +33,6 @@ Rules:
|
||||
- No quotes, periods, or extra formatting
|
||||
- Capture the essence of the feature in a scannable way`;
|
||||
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -89,34 +61,28 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
const stream = query({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeSimpleQuery({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
},
|
||||
model: 'haiku',
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
const title = await extractTextFromStream(stream);
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
logger.warn('Received empty response from Claude');
|
||||
if (!result.success) {
|
||||
logger.warn('Failed to generate title:', result.error);
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: 'Failed to generate title - empty response',
|
||||
error: result.error || 'Failed to generate title',
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generated title: ${title.trim()}`);
|
||||
logger.info(`Generated title: ${result.text}`);
|
||||
|
||||
const response: GenerateTitleSuccessResponse = {
|
||||
success: true,
|
||||
title: title.trim(),
|
||||
title: result.text,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError, isENOENT } from '../common.js';
|
||||
|
||||
const logger = createLogger('FS');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export { isENOENT };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createBrowseHandler() {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getBoardDir } from '@automaker/platform';
|
||||
|
||||
export function createDeleteBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler() {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createExistsHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createImageHandler() {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createMkdirHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError, isENOENT } from '../common.js';
|
||||
|
||||
// Optional files that are expected to not exist in new projects
|
||||
// Don't log ENOENT errors for these to reduce noise
|
||||
@@ -15,10 +14,6 @@ function isOptionalFile(filePath: string): boolean {
|
||||
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export function createReadHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createReaddirHandler() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBoardDir } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getBoardDir } from '@automaker/platform';
|
||||
|
||||
export function createSaveBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getImagesDir } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, isPathAllowed } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { isPathAllowed } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
|
||||
@@ -3,54 +3,16 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
|
||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||
import { createListPRsHandler } from './routes/list-prs.js';
|
||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||
import {
|
||||
createValidationStatusHandler,
|
||||
createValidationStopHandler,
|
||||
createGetValidationsHandler,
|
||||
createDeleteValidationHandler,
|
||||
createMarkViewedHandler,
|
||||
} from './routes/validation-endpoints.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createGitHubRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
export function createGitHubRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||
router.post(
|
||||
'/validate-issue',
|
||||
validatePathParams('projectPath'),
|
||||
createValidateIssueHandler(events, settingsService)
|
||||
);
|
||||
|
||||
// Validation management endpoints
|
||||
router.post(
|
||||
'/validation-status',
|
||||
validatePathParams('projectPath'),
|
||||
createValidationStatusHandler()
|
||||
);
|
||||
router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler());
|
||||
router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler());
|
||||
router.post(
|
||||
'/validation-delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteValidationHandler()
|
||||
);
|
||||
router.post(
|
||||
'/validation-mark-viewed',
|
||||
validatePathParams('projectPath'),
|
||||
createMarkViewedHandler(events)
|
||||
);
|
||||
router.post('/check-remote', createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', createListIssuesHandler());
|
||||
router.post('/prs', createListPRsHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubRemoteStatus } from '@automaker/types';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
|
||||
export interface GitHubRemoteStatus {
|
||||
hasGitHubRemote: boolean;
|
||||
remoteUrl: string | null;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
}
|
||||
// Re-export type for convenience
|
||||
export type { GitHubRemoteStatus } from '@automaker/types';
|
||||
|
||||
export async function checkGitHubRemote(projectPath: string): Promise<GitHubRemoteStatus> {
|
||||
const status: GitHubRemoteStatus = {
|
||||
|
||||
@@ -2,34 +2,16 @@
|
||||
* Common utilities for GitHub routes
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { createLogError, getErrorMessage } from '../../common.js';
|
||||
import { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
const logger = createLogger('GitHub');
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
export const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
// Re-export exec utilities for convenience
|
||||
export { execAsync, execEnv } from '../../../lib/exec-utils.js';
|
||||
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
// Re-export error utilities
|
||||
export { getErrorMessage } from '../../common.js';
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
console.error(`[GitHub] ${context}:`, error);
|
||||
}
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
@@ -2,192 +2,13 @@
|
||||
* POST /list-issues endpoint - List GitHub issues for a project
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubIssue, ListIssuesResult } from '@automaker/types';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface GitHubAssignee {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface LinkedPullRequest {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GitHubIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
assignees: GitHubAssignee[];
|
||||
linkedPRs?: LinkedPullRequest[];
|
||||
}
|
||||
|
||||
export interface ListIssuesResult {
|
||||
success: boolean;
|
||||
openIssues?: GitHubIssue[];
|
||||
closedIssues?: GitHubIssue[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch linked PRs for a list of issues using GitHub GraphQL API
|
||||
*/
|
||||
async function fetchLinkedPRs(
|
||||
projectPath: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumbers: number[]
|
||||
): Promise<Map<number, LinkedPullRequest[]>> {
|
||||
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
|
||||
|
||||
if (issueNumbers.length === 0) {
|
||||
return linkedPRsMap;
|
||||
}
|
||||
|
||||
// Build GraphQL query for batch fetching linked PRs
|
||||
// We fetch up to 20 issues at a time to avoid query limits
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < issueNumbers.length; i += batchSize) {
|
||||
const batch = issueNumbers.slice(i, i + batchSize);
|
||||
|
||||
const issueQueries = batch
|
||||
.map(
|
||||
(num, idx) => `
|
||||
issue${idx}: issue(number: ${num}) {
|
||||
number
|
||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
||||
nodes {
|
||||
... on CrossReferencedEvent {
|
||||
source {
|
||||
... on PullRequest {
|
||||
number
|
||||
title
|
||||
state
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ConnectedEvent {
|
||||
subject {
|
||||
... on PullRequest {
|
||||
number
|
||||
title
|
||||
state
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const query = `{
|
||||
repository(owner: "${owner}", name: "${repo}") {
|
||||
${issueQueries}
|
||||
}
|
||||
}`;
|
||||
|
||||
try {
|
||||
// Use spawn with stdin to avoid shell injection vulnerabilities
|
||||
// --input - reads the JSON request body from stdin
|
||||
const requestBody = JSON.stringify({ query });
|
||||
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||
|
||||
gh.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
gh.stdin.write(requestBody);
|
||||
gh.stdin.end();
|
||||
});
|
||||
|
||||
const repoData = (response?.data as Record<string, unknown>)?.repository as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (repoData) {
|
||||
batch.forEach((issueNum, idx) => {
|
||||
const issueData = repoData[`issue${idx}`] as {
|
||||
timelineItems?: {
|
||||
nodes?: Array<{
|
||||
source?: { number?: number; title?: string; state?: string; url?: string };
|
||||
subject?: { number?: number; title?: string; state?: string; url?: string };
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
if (issueData?.timelineItems?.nodes) {
|
||||
const linkedPRs: LinkedPullRequest[] = [];
|
||||
const seenPRs = new Set<number>();
|
||||
|
||||
for (const node of issueData.timelineItems.nodes) {
|
||||
const pr = node?.source || node?.subject;
|
||||
if (pr?.number && !seenPRs.has(pr.number)) {
|
||||
seenPRs.add(pr.number);
|
||||
linkedPRs.push({
|
||||
number: pr.number,
|
||||
title: pr.title || '',
|
||||
state: (pr.state || '').toLowerCase(),
|
||||
url: pr.url || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedPRs.length > 0) {
|
||||
linkedPRsMap.set(issueNum, linkedPRs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// If GraphQL fails, continue without linked PRs
|
||||
console.warn(
|
||||
'Failed to fetch linked PRs via GraphQL:',
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkedPRsMap;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { GitHubLabel, GitHubAuthor, GitHubIssue, ListIssuesResult } from '@automaker/types';
|
||||
|
||||
export function createListIssuesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -209,17 +30,17 @@ export function createListIssuesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch open and closed issues in parallel (now including assignees)
|
||||
// Fetch open and closed issues in parallel
|
||||
const [openResult, closedResult] = await Promise.all([
|
||||
execAsync(
|
||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
|
||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
execAsync(
|
||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
|
||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
@@ -233,24 +54,6 @@ export function createListIssuesHandler() {
|
||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
||||
|
||||
// Fetch linked PRs for open issues (more relevant for active work)
|
||||
if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) {
|
||||
const linkedPRsMap = await fetchLinkedPRs(
|
||||
projectPath,
|
||||
remoteStatus.owner,
|
||||
remoteStatus.repo,
|
||||
openIssues.map((i) => i.number)
|
||||
);
|
||||
|
||||
// Attach linked PRs to issues
|
||||
for (const issue of openIssues) {
|
||||
const linkedPRs = linkedPRsMap.get(issue.number);
|
||||
if (linkedPRs) {
|
||||
issue.linkedPRs = linkedPRs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
openIssues,
|
||||
|
||||
@@ -3,39 +3,12 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { GitHubPR, ListPRsResult } from '@automaker/types';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
|
||||
export interface GitHubLabel {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
author: GitHubAuthor;
|
||||
createdAt: string;
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
isDraft: boolean;
|
||||
headRefName: string;
|
||||
reviewDecision: string | null;
|
||||
mergeable: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ListPRsResult {
|
||||
success: boolean;
|
||||
openPRs?: GitHubPR[];
|
||||
mergedPRs?: GitHubPR[];
|
||||
error?: string;
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { GitHubLabel, GitHubAuthor, GitHubPR, ListPRsResult } from '@automaker/types';
|
||||
|
||||
export function createListPRsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
|
||||
*
|
||||
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
|
||||
* Runs asynchronously and emits events for progress and completion.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
|
||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import {
|
||||
issueValidationSchema,
|
||||
ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
buildValidationPrompt,
|
||||
} from './validation-schema.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
clearValidationStatus,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
/** Valid model values for validation */
|
||||
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
*/
|
||||
interface ValidateIssueRequestBody {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
issueTitle: string;
|
||||
issueBody: string;
|
||||
issueLabels?: string[];
|
||||
/** Model to use for validation (opus, sonnet, haiku) */
|
||||
model?: AgentModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation asynchronously
|
||||
*
|
||||
* Emits events for start, progress, complete, and error.
|
||||
* Stores result on completion.
|
||||
*/
|
||||
async function runValidation(
|
||||
projectPath: string,
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels: string[] | undefined,
|
||||
model: AgentModel,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
): Promise<void> {
|
||||
// Emit start event
|
||||
const startEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_start',
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', startEvent);
|
||||
|
||||
// Set up timeout (6 minutes)
|
||||
const VALIDATION_TIMEOUT_MS = 360000;
|
||||
const timeoutId = setTimeout(() => {
|
||||
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
|
||||
abortController.abort();
|
||||
}, VALIDATION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Build the prompt
|
||||
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
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: projectPath,
|
||||
model,
|
||||
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: issueValidationSchema as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute the query
|
||||
const stream = query({ prompt, options });
|
||||
let validationResult: IssueValidationResult | null = null;
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Collect assistant text for debugging and emit progress
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
|
||||
// Emit progress event
|
||||
const progressEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_progress',
|
||||
issueNumber,
|
||||
content: block.text,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', progressEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract structured output on success
|
||||
if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
const resultMsg = msg as { structured_output?: IssueValidationResult };
|
||||
if (resultMsg.structured_output) {
|
||||
validationResult = resultMsg.structured_output;
|
||||
logger.debug('Received structured output:', validationResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (msg.type === 'result') {
|
||||
const resultMsg = msg as { subtype?: string };
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid validation output');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Require structured output
|
||||
if (!validationResult) {
|
||||
logger.error('No structured output received from Claude SDK');
|
||||
logger.debug('Raw response text:', responseText);
|
||||
throw new Error('Validation failed: no structured output received');
|
||||
}
|
||||
|
||||
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
|
||||
|
||||
// Store the result
|
||||
await writeValidation(projectPath, issueNumber, {
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
validatedAt: new Date().toISOString(),
|
||||
model,
|
||||
result: validationResult,
|
||||
});
|
||||
|
||||
// Emit completion event
|
||||
const completeEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_complete',
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
result: validationResult,
|
||||
projectPath,
|
||||
model,
|
||||
};
|
||||
events.emit('issue-validation:event', completeEvent);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
logError(error, `Issue #${issueNumber} validation failed`);
|
||||
|
||||
// Emit error event
|
||||
const errorEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_error',
|
||||
issueNumber,
|
||||
error: errorMessage,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', errorEvent);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the handler for validating GitHub issues against the codebase.
|
||||
*
|
||||
* Uses Claude SDK with:
|
||||
* - Read-only tools (Read, Glob, Grep) for codebase analysis
|
||||
* - JSON schema structured output for reliable parsing
|
||||
* - System prompt guiding the validation process
|
||||
* - Async execution with event emission
|
||||
*/
|
||||
export function createValidateIssueHandler(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model = 'opus',
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueTitle || typeof issueTitle !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'issueTitle is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof issueBody !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'issueBody must be a string' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate model parameter at runtime
|
||||
if (!VALID_MODELS.includes(model)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
|
||||
|
||||
// Create abort controller and atomically try to claim validation slot
|
||||
// This prevents TOCTOU race conditions
|
||||
const abortController = new AbortController();
|
||||
if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: `Validation is already running for issue #${issueNumber}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start validation in background (fire-and-forget)
|
||||
runValidation(
|
||||
projectPath,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model,
|
||||
events,
|
||||
abortController,
|
||||
settingsService
|
||||
)
|
||||
.catch((error) => {
|
||||
// Error is already handled inside runValidation (event emitted)
|
||||
logger.debug('Validation error caught in background handler:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
clearValidationStatus(projectPath, issueNumber);
|
||||
});
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Validation started for issue #${issueNumber}`,
|
||||
issueNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, `Issue validation failed`);
|
||||
logger.error('Issue validation error:', error);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Common utilities and state for issue validation routes
|
||||
*
|
||||
* Tracks running validation status per issue to support:
|
||||
* - Checking if a validation is in progress
|
||||
* - Cancelling a running validation
|
||||
* - Preventing duplicate validations for the same issue
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js';
|
||||
|
||||
const logger = createLogger('IssueValidation');
|
||||
|
||||
/**
|
||||
* Status of a validation in progress
|
||||
*/
|
||||
interface ValidationStatus {
|
||||
isRunning: boolean;
|
||||
abortController: AbortController;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of issue number to validation status
|
||||
* Key format: `${projectPath}||${issueNumber}` to support multiple projects
|
||||
* Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\)
|
||||
*/
|
||||
const validationStatusMap = new Map<string, ValidationStatus>();
|
||||
|
||||
/** Maximum age for stale validation entries before cleanup (1 hour) */
|
||||
const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Create a unique key for a validation
|
||||
* Uses `||` as delimiter since `:` appears in Windows paths
|
||||
*/
|
||||
function getValidationKey(projectPath: string, issueNumber: number): string {
|
||||
return `${projectPath}||${issueNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a validation is currently running for an issue
|
||||
*/
|
||||
export function isValidationRunning(projectPath: string, issueNumber: number): boolean {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
const status = validationStatusMap.get(key);
|
||||
return status?.isRunning ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation status for an issue
|
||||
*/
|
||||
export function getValidationStatus(
|
||||
projectPath: string,
|
||||
issueNumber: number
|
||||
): { isRunning: boolean; startedAt?: Date } | null {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
const status = validationStatusMap.get(key);
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
isRunning: status.isRunning,
|
||||
startedAt: status.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running validations for a project
|
||||
*/
|
||||
export function getRunningValidations(projectPath: string): number[] {
|
||||
const runningIssues: number[] = [];
|
||||
const prefix = `${projectPath}||`;
|
||||
for (const [key, status] of validationStatusMap.entries()) {
|
||||
if (status.isRunning && key.startsWith(prefix)) {
|
||||
const issueNumber = parseInt(key.slice(prefix.length), 10);
|
||||
if (!isNaN(issueNumber)) {
|
||||
runningIssues.push(issueNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
return runningIssues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a validation as running
|
||||
*/
|
||||
export function setValidationRunning(
|
||||
projectPath: string,
|
||||
issueNumber: number,
|
||||
abortController: AbortController
|
||||
): void {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
validationStatusMap.set(key, {
|
||||
isRunning: true,
|
||||
abortController,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically try to set a validation as running (check-and-set)
|
||||
* Prevents TOCTOU race conditions when starting validations
|
||||
*
|
||||
* @returns true if successfully claimed, false if already running
|
||||
*/
|
||||
export function trySetValidationRunning(
|
||||
projectPath: string,
|
||||
issueNumber: number,
|
||||
abortController: AbortController
|
||||
): boolean {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
if (validationStatusMap.has(key)) {
|
||||
return false; // Already running
|
||||
}
|
||||
validationStatusMap.set(key, {
|
||||
isRunning: true,
|
||||
abortController,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
return true; // Successfully claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale validation entries (e.g., from crashed validations)
|
||||
* Should be called periodically to prevent memory leaks
|
||||
*/
|
||||
export function cleanupStaleValidations(): number {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
for (const [key, status] of validationStatusMap.entries()) {
|
||||
if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) {
|
||||
status.abortController.abort();
|
||||
validationStatusMap.delete(key);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
if (cleanedCount > 0) {
|
||||
logger.info(`Cleaned up ${cleanedCount} stale validation entries`);
|
||||
}
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear validation status (call when validation completes or errors)
|
||||
*/
|
||||
export function clearValidationStatus(projectPath: string, issueNumber: number): void {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
validationStatusMap.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a running validation
|
||||
*
|
||||
* @returns true if validation was aborted, false if not running
|
||||
*/
|
||||
export function abortValidation(projectPath: string, issueNumber: number): boolean {
|
||||
const key = getValidationKey(projectPath, issueNumber);
|
||||
const status = validationStatusMap.get(key);
|
||||
|
||||
if (!status || !status.isRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
status.abortController.abort();
|
||||
validationStatusMap.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
export { logger };
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* Additional validation endpoints for status, stop, and retrieving stored validations
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IssueValidationEvent } from '@automaker/types';
|
||||
import {
|
||||
isValidationRunning,
|
||||
getValidationStatus,
|
||||
getRunningValidations,
|
||||
abortValidation,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import {
|
||||
readValidation,
|
||||
getAllValidations,
|
||||
getValidationWithFreshness,
|
||||
deleteValidation,
|
||||
markValidationViewed,
|
||||
} from '../../../lib/validation-storage.js';
|
||||
|
||||
/**
|
||||
* POST /validation-status - Check if validation is running for an issue
|
||||
*/
|
||||
export function createValidationStatusHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber } = req.body as {
|
||||
projectPath: string;
|
||||
issueNumber?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If issueNumber provided, check specific issue
|
||||
if (issueNumber !== undefined) {
|
||||
const status = getValidationStatus(projectPath, issueNumber);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: status?.isRunning ?? false,
|
||||
startedAt: status?.startedAt?.toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, return all running validations for the project
|
||||
const runningIssues = getRunningValidations(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
runningIssues,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Validation status check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validation-stop - Cancel a running validation
|
||||
*/
|
||||
export function createValidationStopHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber } = req.body as {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
const wasAborted = abortValidation(projectPath, issueNumber);
|
||||
|
||||
if (wasAborted) {
|
||||
logger.info(`Validation for issue #${issueNumber} was stopped`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Validation for issue #${issueNumber} has been stopped`,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: false,
|
||||
error: `No validation is running for issue #${issueNumber}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Validation stop failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validations - Get stored validations for a project
|
||||
*/
|
||||
export function createGetValidationsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber } = req.body as {
|
||||
projectPath: string;
|
||||
issueNumber?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If issueNumber provided, get specific validation with freshness info
|
||||
if (issueNumber !== undefined) {
|
||||
const result = await getValidationWithFreshness(projectPath, issueNumber);
|
||||
|
||||
if (!result) {
|
||||
res.json({
|
||||
success: true,
|
||||
validation: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
validation: result.validation,
|
||||
isStale: result.isStale,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, get all validations for the project
|
||||
const validations = await getAllValidations(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
validations,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get validations failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validation-delete - Delete a stored validation
|
||||
*/
|
||||
export function createDeleteValidationHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber } = req.body as {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await deleteValidation(projectPath, issueNumber);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Delete validation failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validation-mark-viewed - Mark a validation as viewed by the user
|
||||
*/
|
||||
export function createMarkViewedHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, issueNumber } = req.body as {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!issueNumber || typeof issueNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'issueNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await markValidationViewed(projectPath, issueNumber);
|
||||
|
||||
if (success) {
|
||||
// Emit event so UI can update the unviewed count
|
||||
const viewedEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_viewed',
|
||||
issueNumber,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', viewedEvent);
|
||||
}
|
||||
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
logError(error, 'Mark validation viewed failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Issue Validation Schema and System Prompt
|
||||
*
|
||||
* Defines the JSON schema for Claude's structured output and
|
||||
* the system prompt that guides the validation process.
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON Schema for issue validation structured output.
|
||||
* Used with Claude SDK's outputFormat option to ensure reliable parsing.
|
||||
*/
|
||||
export const issueValidationSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verdict: {
|
||||
type: 'string',
|
||||
enum: ['valid', 'invalid', 'needs_clarification'],
|
||||
description: 'The validation verdict for the issue',
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
enum: ['high', 'medium', 'low'],
|
||||
description: 'How confident the AI is in its assessment',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
description: 'Detailed explanation of the verdict',
|
||||
},
|
||||
bugConfirmed: {
|
||||
type: 'boolean',
|
||||
description: 'For bug reports: whether the bug was confirmed in the codebase',
|
||||
},
|
||||
relatedFiles: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Files related to the issue found during analysis',
|
||||
},
|
||||
suggestedFix: {
|
||||
type: 'string',
|
||||
description: 'Suggested approach to fix or implement the issue',
|
||||
},
|
||||
missingInfo: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Information needed when verdict is needs_clarification',
|
||||
},
|
||||
estimatedComplexity: {
|
||||
type: 'string',
|
||||
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
|
||||
description: 'Estimated effort to address the issue',
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'confidence', 'reasoning'],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* System prompt that guides Claude in validating GitHub issues.
|
||||
* Instructs the model to use read-only tools to analyze the codebase.
|
||||
*/
|
||||
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
|
||||
|
||||
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
|
||||
|
||||
## Validation Process
|
||||
|
||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
||||
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
|
||||
## Verdicts
|
||||
|
||||
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
|
||||
|
||||
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
|
||||
|
||||
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
|
||||
|
||||
## For Bug Reports, Check:
|
||||
- Do the referenced files/components exist?
|
||||
- Does the code match what the issue describes?
|
||||
- Is the described behavior actually a bug or expected?
|
||||
- Can you locate the code that would cause the reported issue?
|
||||
|
||||
## For Feature Requests, Check:
|
||||
- Does the feature already exist?
|
||||
- Is the implementation location clear?
|
||||
- Is the request technically feasible given the codebase structure?
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
- **Always include relatedFiles** when you find relevant code
|
||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
||||
- **Set estimatedComplexity** to help prioritize:
|
||||
- trivial: Simple text changes, one-line fixes
|
||||
- simple: Small changes to one file
|
||||
- moderate: Changes to multiple files or moderate logic changes
|
||||
- complex: Significant refactoring or new feature implementation
|
||||
- very_complex: Major architectural changes or cross-cutting concerns
|
||||
|
||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
||||
|
||||
/**
|
||||
* Build the user prompt for issue validation.
|
||||
*
|
||||
* Creates a structured prompt that includes the issue details for Claude
|
||||
* to analyze against the codebase.
|
||||
*
|
||||
* @param issueNumber - The GitHub issue number
|
||||
* @param issueTitle - The issue title
|
||||
* @param issueBody - The issue body/description
|
||||
* @param issueLabels - Optional array of label names
|
||||
* @returns Formatted prompt string for the validation request
|
||||
*/
|
||||
export function buildValidationPrompt(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels?: string[]
|
||||
): string {
|
||||
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
|
||||
|
||||
return `Please validate the following GitHub issue by analyzing the codebase:
|
||||
|
||||
## Issue #${issueNumber}: ${issueTitle}
|
||||
${labelsSection}
|
||||
|
||||
### Description
|
||||
|
||||
${issueBody || '(No description provided)'}
|
||||
|
||||
---
|
||||
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
|
||||
}
|
||||
@@ -1,99 +1,15 @@
|
||||
/**
|
||||
* Business logic for generating suggestions
|
||||
*
|
||||
* Uses ClaudeProvider.executeStreamingQuery() for SDK interaction.
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
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';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -127,8 +43,7 @@ export async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService
|
||||
abortController: AbortController
|
||||
): Promise<void> {
|
||||
const typePrompts: Record<string, string> = {
|
||||
features: 'Analyze this project and suggest new features that would add value.',
|
||||
@@ -137,13 +52,8 @@ export async function generateSuggestions(
|
||||
performance: 'Analyze this project for performance issues and suggest optimizations.',
|
||||
};
|
||||
|
||||
// Load existing context to avoid duplicates
|
||||
const existingContext = await loadExistingContext(projectPath);
|
||||
|
||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
||||
${existingContext}
|
||||
|
||||
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
|
||||
Look at the codebase and provide 3-5 concrete suggestions.
|
||||
|
||||
For each suggestion, provide:
|
||||
@@ -154,73 +64,49 @@ For each suggestion, provide:
|
||||
|
||||
The response will be automatically formatted as structured JSON.`;
|
||||
|
||||
// Don't send initial message - let the agent output speak for itself
|
||||
// The first agent message will be captured as an info entry
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: `Starting ${suggestionType} analysis...\n`,
|
||||
});
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
const provider = ProviderFactory.getProviderForModel('haiku');
|
||||
const result = await provider.executeStreamingQuery({
|
||||
prompt,
|
||||
model: 'haiku',
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
},
|
||||
onText: (text) => {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: text,
|
||||
});
|
||||
},
|
||||
onToolUse: (name, input) => {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool: name,
|
||||
input,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = '';
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
// Check for structured output
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.structured_output) {
|
||||
structuredOutput = resultMsg.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
}
|
||||
} else if (msg.type === 'result') {
|
||||
const resultMsg = msg as any;
|
||||
if (resultMsg.subtype === 'error_max_structured_output_retries') {
|
||||
logger.error('Failed to produce valid structured output after retries');
|
||||
throw new Error('Could not produce valid suggestions output');
|
||||
} else if (resultMsg.subtype === 'error_max_turns') {
|
||||
logger.error('Hit max turns limit before completing suggestions generation');
|
||||
logger.warn(`Response text length: ${responseText.length} chars`);
|
||||
// Still try to parse what we have
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use structured output if available, otherwise fall back to parsing text
|
||||
try {
|
||||
const structuredOutput = result.structuredOutput as
|
||||
| {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (structuredOutput && structuredOutput.suggestions) {
|
||||
// Use structured output directly
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
@@ -231,7 +117,7 @@ The response will be automatically formatted as structured JSON.`;
|
||||
} else {
|
||||
// Fallback: try to parse from text (for backwards compatibility)
|
||||
logger.warn('No structured output received, attempting to parse from text');
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
const jsonMatch = result.text.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
events.emit('suggestions:event', {
|
||||
|
||||
@@ -8,19 +8,11 @@ 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 type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createSuggestionsRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
export function createSuggestionsRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/generate', validatePathParams('projectPath'), createGenerateHandler(events));
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import { generateSuggestions } from '../generate-suggestions.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
export function createGenerateHandler(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, suggestionType = 'features' } = req.body as {
|
||||
@@ -38,7 +37,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(projectPath, suggestionType, events, abortController, settingsService)
|
||||
generateSuggestions(projectPath, suggestionType, events, abortController)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate suggestions failed (background)');
|
||||
events.emit('suggestions:event', {
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { secureFs, PathNotAllowedError } from '@automaker/platform';
|
||||
import { logger, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCloneHandler() {
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Common utilities for update routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Updates');
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env['ProgramFiles(x86)']) {
|
||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||
'/home/linuxbrew/.linuxbrew/bin' // Linuxbrew
|
||||
);
|
||||
// pipx, other user installs - only add if HOME is defined
|
||||
if (process.env.HOME) {
|
||||
additionalPaths.push(`${process.env.HOME}/.local/bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||
.filter(Boolean)
|
||||
.join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Automaker installation path
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Locate the Automaker monorepo root directory.
|
||||
*
|
||||
* @returns Absolute path to the monorepo root directory (the directory containing the top-level `package.json`)
|
||||
*/
|
||||
export function getAutomakerRoot(): string {
|
||||
// In ESM, we use import.meta.url to get the current file path
|
||||
// This file is at: apps/server/src/routes/updates/common.ts
|
||||
// So we need to go up 5 levels to get to the monorepo root
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Go up from: updates -> routes -> src -> server -> apps -> root
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether Git is available on the system.
|
||||
*
|
||||
* @returns `true` if the `git` command is executable in the current environment, `false` otherwise.
|
||||
*/
|
||||
export async function isGitAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git --version', { env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the given filesystem path is a Git repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path to check
|
||||
* @returns `true` if the path is inside a Git working tree, `false` otherwise.
|
||||
*/
|
||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the full commit hash pointed to by HEAD in the given repository.
|
||||
*
|
||||
* @param repoPath - Filesystem path of the Git repository to query
|
||||
* @returns The full commit hash for HEAD as a trimmed string
|
||||
*/
|
||||
export async function getCurrentCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the short commit hash of HEAD for the repository at the given path.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the git repository
|
||||
* @returns The short commit hash for `HEAD`
|
||||
*/
|
||||
export async function getShortCommit(repoPath: string): Promise<string> {
|
||||
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the repository contains uncommitted local changes.
|
||||
*
|
||||
* @param repoPath - Filesystem path to the Git repository to check
|
||||
* @returns `true` if the repository has any uncommitted changes, `false` otherwise
|
||||
*/
|
||||
export async function hasLocalChanges(repoPath: string): Promise<boolean> {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv });
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a string is a well-formed git remote URL and contains no shell metacharacters.
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns `true` if `url` starts with a common git protocol (`https://`, `git@`, `git://`, `ssh://`) and does not contain shell metacharacters, `false` otherwise.
|
||||
*/
|
||||
export function isValidGitUrl(url: string): boolean {
|
||||
// Allow HTTPS, SSH, and git protocols
|
||||
const startsWithValidProtocol =
|
||||
url.startsWith('https://') ||
|
||||
url.startsWith('git@') ||
|
||||
url.startsWith('git://') ||
|
||||
url.startsWith('ssh://');
|
||||
|
||||
// Block shell metacharacters to prevent command injection
|
||||
const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url);
|
||||
|
||||
return startsWithValidProtocol && !hasShellChars;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Update routes - HTTP API for checking and applying updates
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Checking if updates are available from upstream
|
||||
* - Pulling updates from upstream
|
||||
* - Getting current installation info
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { createCheckHandler } from './routes/check.js';
|
||||
import { createPullHandler } from './routes/pull.js';
|
||||
import { createInfoHandler } from './routes/info.js';
|
||||
|
||||
/**
|
||||
* Create an Express Router that exposes API endpoints for update operations.
|
||||
*
|
||||
* @returns An Express Router with the routes:
|
||||
* - GET `/check` — checks for available updates
|
||||
* - POST `/pull` — pulls updates from upstream
|
||||
* - GET `/info` — returns current installation info
|
||||
*/
|
||||
export function createUpdatesRoutes(settingsService: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /api/updates/check - Check if updates are available
|
||||
router.get('/check', createCheckHandler(settingsService));
|
||||
|
||||
// POST /api/updates/pull - Pull updates from upstream
|
||||
router.post('/pull', createPullHandler(settingsService));
|
||||
|
||||
// GET /api/updates/info - Get current installation info
|
||||
router.get('/info', createInfoHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* GET /check endpoint - Check if updates are available
|
||||
*
|
||||
* Compares local version with the remote upstream version.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdateCheckResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for the update check endpoint that compares the local Git commit
|
||||
* against a configured upstream to determine whether an update is available.
|
||||
*
|
||||
* The handler validates Git availability and repository state, reads the upstream URL from
|
||||
* global settings (with a default), attempts to fetch the upstream main branch using a
|
||||
* temporary remote, and returns a structured result describing local and remote commits and
|
||||
* whether the remote is ahead.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain `autoUpdate.upstreamUrl`)
|
||||
* @returns An Express request handler that responds with JSON. On success the response is
|
||||
* `{ success: true, result }` where `result` is an `UpdateCheckResult`. On error the response
|
||||
* is `{ success: false, error }`. If fetching the upstream fails the handler still responds
|
||||
* with `{ success: true, result }` where `result` indicates no update and includes an `error` message.
|
||||
*/
|
||||
export function createCheckHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get local version
|
||||
const localVersion = await getCurrentCommit(installPath);
|
||||
const localVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent checks
|
||||
const tempRemoteName = `automaker-update-check-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch from the temporary remote
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Get remote version
|
||||
const { stdout: remoteVersionOutput } = await execAsync(
|
||||
`git rev-parse ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersion = remoteVersionOutput.trim();
|
||||
|
||||
// Get short remote version
|
||||
const { stdout: remoteVersionShortOutput } = await execAsync(
|
||||
`git rev-parse --short ${tempRemoteName}/main`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
const remoteVersionShort = remoteVersionShortOutput.trim();
|
||||
|
||||
// Check if remote is ahead of local (update available)
|
||||
// git merge-base --is-ancestor <commit1> <commit2> returns 0 if commit1 is ancestor of commit2
|
||||
let updateAvailable = false;
|
||||
if (localVersion !== remoteVersion) {
|
||||
try {
|
||||
// Check if local is already an ancestor of remote (remote is ahead)
|
||||
await execAsync(`git merge-base --is-ancestor ${localVersion} ${remoteVersion}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
// If we get here (exit code 0), local is ancestor of remote, so update is available
|
||||
updateAvailable = true;
|
||||
} catch {
|
||||
// Exit code 1 means local is NOT an ancestor of remote
|
||||
// This means either local is ahead, or branches have diverged
|
||||
// In either case, we don't show "update available"
|
||||
updateAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
const result: UpdateCheckResult = {
|
||||
updateAvailable,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion,
|
||||
remoteVersionShort,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const errorMsg = getErrorMessage(fetchError);
|
||||
logError(fetchError, 'Failed to fetch from upstream');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
updateAvailable: false,
|
||||
localVersion,
|
||||
localVersionShort,
|
||||
remoteVersion: null,
|
||||
remoteVersionShort: null,
|
||||
sourceUrl,
|
||||
installPath,
|
||||
error: `Could not fetch from upstream: ${errorMsg}`,
|
||||
} satisfies UpdateCheckResult,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* GET /info endpoint - Get current installation info
|
||||
*
|
||||
* Returns current version, branch, and configuration info.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { DEFAULT_AUTO_UPDATE_SETTINGS, type UpdateInfo } from '@automaker/types';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates an Express handler that returns update information for the application installation.
|
||||
*
|
||||
* The produced handler responds with a JSON payload containing an UpdateInfo result describing
|
||||
* installation path, git-based version and branch data (when available), local change status,
|
||||
* and configured auto-update settings. On failure the handler responds with HTTP 500 and a JSON
|
||||
* error message.
|
||||
*
|
||||
* @returns An Express request handler that sends `{ success: true, result: UpdateInfo }` on success
|
||||
* or `{ success: false, error: string }` with HTTP 500 on error.
|
||||
*/
|
||||
export function createInfoHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Get settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const autoUpdateSettings = settings.autoUpdate || DEFAULT_AUTO_UPDATE_SETTINGS;
|
||||
|
||||
// Check if git is available
|
||||
const gitAvailable = await isGitAvailable();
|
||||
|
||||
if (!gitAvailable) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: false,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
const isRepo = await isGitRepo(installPath);
|
||||
|
||||
if (!isRepo) {
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion: null,
|
||||
currentVersionShort: null,
|
||||
currentBranch: null,
|
||||
hasLocalChanges: false,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: false,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get git info
|
||||
const currentVersion = await getCurrentCommit(installPath);
|
||||
const currentVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
const currentBranch = branchOutput.trim();
|
||||
|
||||
// Check for local changes
|
||||
const localChanges = await hasLocalChanges(installPath);
|
||||
|
||||
const result: UpdateInfo = {
|
||||
installPath,
|
||||
currentVersion,
|
||||
currentVersionShort,
|
||||
currentBranch,
|
||||
hasLocalChanges: localChanges,
|
||||
sourceUrl: autoUpdateSettings.upstreamUrl,
|
||||
autoUpdateEnabled: autoUpdateSettings.enabled,
|
||||
checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes,
|
||||
updateType: 'git',
|
||||
mechanismInfo: {
|
||||
isGitRepo: true,
|
||||
gitAvailable: true,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Failed to get update info');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* POST /pull endpoint - Pull updates from upstream
|
||||
*
|
||||
* Executes git pull from the configured upstream repository.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { UpdatePullResult } from '@automaker/types';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
execAsync,
|
||||
execEnv,
|
||||
getAutomakerRoot,
|
||||
getCurrentCommit,
|
||||
getShortCommit,
|
||||
isGitRepo,
|
||||
isGitAvailable,
|
||||
isValidGitUrl,
|
||||
hasLocalChanges,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
/**
|
||||
* Create an Express handler for POST /pull that updates the local Automaker installation by pulling from the configured upstream Git repository.
|
||||
*
|
||||
* The handler validates Git availability and that the install directory is a git repository, ensures there are no local uncommitted changes, validates the upstream URL from global settings, and performs a fast-forward-only pull using a temporary remote. It returns a JSON UpdatePullResult on success, or an error JSON with appropriate HTTP status codes for invalid input, merge conflicts, non-fast-forward divergence, or unexpected failures.
|
||||
*
|
||||
* @param settingsService - Service used to read global settings (used to obtain the upstream URL)
|
||||
* @returns An Express request handler that performs the safe fast-forward pull and sends a JSON response describing the result or error
|
||||
*/
|
||||
export function createPullHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const installPath = getAutomakerRoot();
|
||||
|
||||
// Check if git is available
|
||||
if (!(await isGitAvailable())) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Git is not installed or not available in PATH',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if automaker directory is a git repo
|
||||
if (!(await isGitRepo(installPath))) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Automaker installation is not a git repository',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for local changes
|
||||
if (await hasLocalChanges(installPath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'You have local uncommitted changes. Please commit or stash them before updating.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for upstream URL
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const sourceUrl =
|
||||
settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git';
|
||||
|
||||
// Validate URL to prevent command injection
|
||||
if (!isValidGitUrl(sourceUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid upstream URL format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current version before pull
|
||||
const previousVersion = await getCurrentCommit(installPath);
|
||||
const previousVersionShort = await getShortCommit(installPath);
|
||||
|
||||
// Use a random remote name to avoid conflicts with concurrent pulls
|
||||
const tempRemoteName = `automaker-update-pull-${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
try {
|
||||
// Add temporary remote
|
||||
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Fetch first
|
||||
await execAsync(`git fetch ${tempRemoteName} main`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Merge the fetched changes
|
||||
const { stdout: mergeOutput } = await execAsync(
|
||||
`git merge ${tempRemoteName}/main --ff-only`,
|
||||
{ cwd: installPath, env: execEnv }
|
||||
);
|
||||
|
||||
// Get new version after merge
|
||||
const newVersion = await getCurrentCommit(installPath);
|
||||
const newVersionShort = await getShortCommit(installPath);
|
||||
|
||||
const alreadyUpToDate =
|
||||
mergeOutput.includes('Already up to date') || previousVersion === newVersion;
|
||||
|
||||
const result: UpdatePullResult = {
|
||||
success: true,
|
||||
previousVersion,
|
||||
previousVersionShort,
|
||||
newVersion,
|
||||
newVersionShort,
|
||||
alreadyUpToDate,
|
||||
message: alreadyUpToDate
|
||||
? 'Already up to date'
|
||||
: `Updated from ${previousVersionShort} to ${newVersionShort}`,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (pullError) {
|
||||
const errorMsg = getErrorMessage(pullError);
|
||||
logError(pullError, 'Failed to pull updates');
|
||||
|
||||
// Check for common errors
|
||||
if (errorMsg.includes('not possible to fast-forward')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot fast-forward merge. Your local branch has diverged from upstream. Please resolve manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMsg.includes('CONFLICT')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Merge conflict detected. Please resolve conflicts manually.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Failed to pull updates: ${errorMsg}`,
|
||||
});
|
||||
} finally {
|
||||
// Always clean up temp remote
|
||||
try {
|
||||
await execAsync(`git remote remove ${tempRemoteName}`, {
|
||||
cwd: installPath,
|
||||
env: execEnv,
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Update pull failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createConfigHandler() {
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDirectoriesHandler() {
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
import { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
const logger = createLogger('Worktree');
|
||||
export const execAsync = promisify(exec);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// Re-export exec utilities for convenience
|
||||
export { execAsync, execEnv, isENOENT } from '../../lib/exec-utils.js';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
@@ -20,48 +20,6 @@ const featureLoader = new FeatureLoader();
|
||||
/** Maximum allowed length for git branch names */
|
||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env['ProgramFiles(x86)']) {
|
||||
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
'/opt/homebrew/bin', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
|
||||
'/home/linuxbrew/.linuxbrew/bin', // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin` // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
|
||||
.filter(Boolean)
|
||||
.join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||
* common tool installation locations.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Validation utilities
|
||||
// ============================================================================
|
||||
@@ -111,27 +69,6 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
||||
* Returns false for freshly initialized repos with no commits
|
||||
*/
|
||||
export async function hasCommits(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||
* These are expected in test environments with mock paths
|
||||
*/
|
||||
export function isENOENT(error: unknown): boolean {
|
||||
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a mock/test path that doesn't exist
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||
import { createInfoHandler } from './routes/info.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
@@ -39,42 +38,17 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post('/list', createListHandler());
|
||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||
router.post(
|
||||
'/merge',
|
||||
validatePathParams('projectPath'),
|
||||
requireValidProject,
|
||||
createMergeHandler()
|
||||
);
|
||||
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
router.post(
|
||||
'/commit',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createCommitHandler()
|
||||
);
|
||||
router.post(
|
||||
'/push',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createPushHandler()
|
||||
);
|
||||
router.post(
|
||||
'/pull',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createPullHandler()
|
||||
);
|
||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
||||
router.post(
|
||||
'/list-branches',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createListBranchesHandler()
|
||||
);
|
||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
|
||||
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
|
||||
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
|
||||
router.post('/checkout-branch', createCheckoutBranchHandler());
|
||||
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
|
||||
router.post('/switch-branch', createSwitchBranchHandler());
|
||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Middleware for worktree route validation
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { isGitRepo, hasCommits } from './common.js';
|
||||
|
||||
interface ValidationOptions {
|
||||
/** Check if the path is a git repository (default: true) */
|
||||
requireGitRepo?: boolean;
|
||||
/** Check if the repository has at least one commit (default: true) */
|
||||
requireCommits?: boolean;
|
||||
/** The name of the request body field containing the path (default: 'worktreePath') */
|
||||
pathField?: 'worktreePath' | 'projectPath';
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory to validate that a path is a valid git repository with commits.
|
||||
* This reduces code duplication across route handlers.
|
||||
*
|
||||
* @param options - Validation options
|
||||
* @returns Express middleware function
|
||||
*/
|
||||
export function requireValidGitRepo(options: ValidationOptions = {}) {
|
||||
const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options;
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const repoPath = req.body[pathField] as string | undefined;
|
||||
|
||||
if (!repoPath) {
|
||||
// Let the route handler deal with missing path validation
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireGitRepo && !(await isGitRepo(repoPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Not a git repository',
|
||||
code: 'NOT_GIT_REPO',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireCommits && !(await hasCommits(repoPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Repository has no commits yet',
|
||||
code: 'NO_COMMITS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo for worktreePath field
|
||||
*/
|
||||
export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' });
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo for projectPath field
|
||||
*/
|
||||
export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' });
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo without requiring commits (for commit route)
|
||||
*/
|
||||
export const requireGitRepoOnly = requireValidGitRepo({
|
||||
pathField: 'worktreePath',
|
||||
requireCommits: false,
|
||||
});
|
||||
@@ -5,15 +5,12 @@
|
||||
* can switch between branches even after worktrees are removed.
|
||||
*/
|
||||
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs, getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { TrackedBranch } from '@automaker/types';
|
||||
import path from 'path';
|
||||
import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
|
||||
export interface TrackedBranch {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastActivatedAt?: string;
|
||||
}
|
||||
// Re-export type for convenience
|
||||
export type { TrackedBranch } from '@automaker/types';
|
||||
|
||||
interface BranchTrackingData {
|
||||
branches: TrackedBranch[];
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /commit endpoint - Commit changes in a worktree
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -56,56 +56,32 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
console.log(`[CreatePR] Checking for uncommitted changes in: ${worktreePath}`);
|
||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
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
|
||||
let commitHash: string | null = null;
|
||||
let commitError: string | null = null;
|
||||
if (hasChanges) {
|
||||
const message = commitMessage || `Changes from ${branchName}`;
|
||||
console.log(`[CreatePR] Committing changes with message: ${message}`);
|
||||
|
||||
try {
|
||||
// Stage all changes
|
||||
console.log(`[CreatePR] Running: git add -A`);
|
||||
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
||||
// Stage all changes
|
||||
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
|
||||
|
||||
// Create commit
|
||||
console.log(`[CreatePR] Running: git commit`);
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
// Create commit
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
// Get commit hash
|
||||
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
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;
|
||||
}
|
||||
// Get commit hash
|
||||
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
commitHash = hashOutput.trim().substring(0, 8);
|
||||
}
|
||||
|
||||
// Push the branch to remote
|
||||
@@ -384,9 +360,8 @@ export function createCreatePRHandler() {
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
committed: hasChanges && !commitError,
|
||||
committed: hasChanges,
|
||||
commitHash,
|
||||
commitError: commitError || undefined,
|
||||
pushed: true,
|
||||
prUrl,
|
||||
prNumber,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import {
|
||||
isGitRepo,
|
||||
getErrorMessage,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getGitRepositoryDiffs } from '../../common.js';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { generateSyntheticDiffForNewFile } from '../../common.js';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { join } from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /list-branches endpoint - List all local branches
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidProject middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PRComment, PRInfo } from '@automaker/types';
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
@@ -12,26 +13,8 @@ import {
|
||||
isGhCliAvailable,
|
||||
} from '../common.js';
|
||||
|
||||
export interface PRComment {
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: PRComment[];
|
||||
reviewComments: PRComment[];
|
||||
}
|
||||
// Re-export types for convenience
|
||||
export type { PRComment, PRInfo } from '@automaker/types';
|
||||
|
||||
export function createPRInfoHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /push endpoint - Push a worktree branch to remote
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
* Simple branch switching.
|
||||
* If there are uncommitted changes, the switch will fail and
|
||||
* the user should commit first.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import {
|
||||
@@ -12,16 +11,13 @@ import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { PathNotAllowedError, secureFs } from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('AgentService');
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -36,14 +32,6 @@ interface Message {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface QueuedPrompt {
|
||||
id: string;
|
||||
message: string;
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
messages: Message[];
|
||||
isRunning: boolean;
|
||||
@@ -51,7 +39,6 @@ interface Session {
|
||||
workingDirectory: string;
|
||||
model?: string;
|
||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
@@ -72,13 +59,11 @@ export class AgentService {
|
||||
private stateDir: string;
|
||||
private metadataFile: string;
|
||||
private events: EventEmitter;
|
||||
private settingsService: SettingsService | null = null;
|
||||
|
||||
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) {
|
||||
constructor(dataDir: string, events: EventEmitter) {
|
||||
this.stateDir = path.join(dataDir, 'agent-sessions');
|
||||
this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
|
||||
this.events = events;
|
||||
this.settingsService = settingsService ?? null;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -107,16 +92,12 @@ export class AgentService {
|
||||
// Validate that the working directory is allowed using centralized validation
|
||||
validateWorkingDirectory(resolvedWorkingDirectory);
|
||||
|
||||
// Load persisted queue
|
||||
const promptQueue = await this.loadQueueState(sessionId);
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
messages,
|
||||
isRunning: false,
|
||||
abortController: null,
|
||||
workingDirectory: resolvedWorkingDirectory,
|
||||
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
|
||||
promptQueue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,12 +127,10 @@ export class AgentService {
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error('[AgentService] ERROR: Session not found:', sessionId);
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.isRunning) {
|
||||
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
@@ -173,7 +152,7 @@ export class AgentService {
|
||||
filename: imageData.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
logger.error(`Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,11 +176,6 @@ export class AgentService {
|
||||
session.isRunning = true;
|
||||
session.abortController = new AbortController();
|
||||
|
||||
// Emit started event so UI can show thinking indicator
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'started',
|
||||
});
|
||||
|
||||
// Emit user message event
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'message',
|
||||
@@ -214,29 +188,12 @@ export class AgentService {
|
||||
// Determine the effective working directory for context loading
|
||||
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 project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
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
|
||||
const baseSystemPrompt = this.getSystemPrompt();
|
||||
const combinedSystemPrompt = contextFilesPrompt
|
||||
@@ -250,8 +207,6 @@ export class AgentService {
|
||||
sessionModel: session.model,
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -262,18 +217,18 @@ export class AgentService {
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
logger.info(`Using provider "${provider.getName()}" for model "${effectiveModel}"`);
|
||||
|
||||
// Build options for provider
|
||||
const options: ExecuteOptions = {
|
||||
prompt: '', // Will be set below based on images
|
||||
model: effectiveModel,
|
||||
cwd: effectiveWorkDir,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
maxTurns: maxTurns,
|
||||
allowedTools: allowedTools,
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
};
|
||||
|
||||
@@ -299,6 +254,7 @@ export class AgentService {
|
||||
// Capture SDK session ID from any message and persist it
|
||||
if (msg.session_id && !session.sdkSessionId) {
|
||||
session.sdkSessionId = msg.session_id;
|
||||
logger.info(`Captured SDK session ID: ${msg.session_id}`);
|
||||
// Persist the SDK session ID to ensure conversation continuity across server restarts
|
||||
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
|
||||
}
|
||||
@@ -363,9 +319,6 @@ export class AgentService {
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
// Process next item in queue after completion
|
||||
setImmediate(() => this.processNextInQueue(sessionId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: currentAssistantMessage,
|
||||
@@ -377,7 +330,7 @@ export class AgentService {
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
|
||||
console.error('[AgentService] Error:', error);
|
||||
logger.error('Error:', error);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
@@ -471,7 +424,7 @@ export class AgentService {
|
||||
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
|
||||
await this.updateSessionTimestamp(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[AgentService] Failed to save session:', error);
|
||||
logger.error('Failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,165 +557,6 @@ export class AgentService {
|
||||
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 {
|
||||
this.events.emit('agent:stream', { sessionId, ...data });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
162
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
162
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Feature Verification Service - Handles verification and commit operations
|
||||
*
|
||||
* Provides functionality to verify feature implementations (lint, typecheck, test, build)
|
||||
* and commit changes to git.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
runVerificationChecks,
|
||||
hasUncommittedChanges,
|
||||
commitAll,
|
||||
shortHash,
|
||||
} from '@automaker/git-utils';
|
||||
import { extractTitleFromDescription } from '@automaker/prompts';
|
||||
import { getFeatureDir, secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('FeatureVerification');
|
||||
|
||||
export interface VerificationResult {
|
||||
success: boolean;
|
||||
failedCheck?: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
hash: string | null;
|
||||
shortHash?: string;
|
||||
}
|
||||
|
||||
export class FeatureVerificationService {
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a feature (checks for worktree)
|
||||
*/
|
||||
async resolveWorkDir(projectPath: string, featureId: string): Promise<string> {
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
return worktreePath;
|
||||
} catch {
|
||||
return projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a feature's implementation by running checks
|
||||
*/
|
||||
async verify(projectPath: string, featureId: string): Promise<VerificationResult> {
|
||||
const workDir = await this.resolveWorkDir(projectPath, featureId);
|
||||
|
||||
const result = await runVerificationChecks(workDir);
|
||||
|
||||
if (result.success) {
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: 'All verification checks passed',
|
||||
});
|
||||
} else {
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: false,
|
||||
message: `Verification failed: ${result.failedCheck}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit feature changes
|
||||
*/
|
||||
async commit(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
feature: Feature | null,
|
||||
providedWorktreePath?: string
|
||||
): Promise<CommitResult> {
|
||||
let workDir = projectPath;
|
||||
|
||||
if (providedWorktreePath) {
|
||||
try {
|
||||
await secureFs.access(providedWorktreePath);
|
||||
workDir = providedWorktreePath;
|
||||
} catch {
|
||||
// Use project path
|
||||
}
|
||||
} else {
|
||||
workDir = await this.resolveWorkDir(projectPath, featureId);
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
const hasChanges = await hasUncommittedChanges(workDir);
|
||||
if (!hasChanges) {
|
||||
return { hash: null };
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
const title = feature
|
||||
? extractTitleFromDescription(feature.description)
|
||||
: `Feature ${featureId}`;
|
||||
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
|
||||
|
||||
// Commit changes
|
||||
const hash = await commitAll(workDir, commitMessage);
|
||||
|
||||
if (hash) {
|
||||
const short = shortHash(hash);
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Changes committed: ${short}`,
|
||||
});
|
||||
return { hash, shortHash: short };
|
||||
}
|
||||
|
||||
logger.error(`Commit failed for ${featureId}`);
|
||||
return { hash: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context (agent-output.md) exists for a feature
|
||||
*/
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing context for a feature
|
||||
*/
|
||||
async loadContext(projectPath: string, featureId: string): Promise<string | null> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
try {
|
||||
return (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(eventType: string, data: Record<string, unknown>): void {
|
||||
this.events.emit('auto-mode:event', { type: eventType, ...data });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user