Compare commits

..

1 Commits

Author SHA1 Message Date
SuperComboGamer
379976aba7 feat: implement new layout components and enhance UI with glassmorphism
This commit introduces a new app layout structure with an AppLayout component, a TopHeader for improved navigation, and a Sidebar for project management. Additionally, it adds GlassPanel and GlassCard components to enhance the UI with a glassmorphism effect. The Kanban board and agent views have been updated to utilize these new components, improving the overall user experience and visual consistency across the application.
2025-12-23 16:38:49 -05:00
257 changed files with 5656 additions and 26260 deletions

6
.gitignore vendored
View File

@@ -79,8 +79,4 @@ blob-report/
# Misc
*.pem
docker-compose.override.yml
.claude/docker-compose.override.yml
pnpm-lock.yaml
yarn.lock
docker-compose.override.yml

View File

@@ -1,134 +0,0 @@
# Improved Error Handling for Rate Limiting
## Problem
When running multiple features concurrently in auto-mode, the Claude API rate limits were being exceeded, resulting in cryptic error messages:
```
Error: Claude Code process exited with code 1
```
This error provided no actionable information to users about:
- What went wrong (rate limit exceeded)
- How long to wait before retrying
- How to prevent it in the future
## Root Cause
The Claude Agent SDK was terminating with exit code 1 when hitting rate limits (HTTP 429), but the error details were not being properly surfaced to the user. The error handling code only showed the generic exit code message instead of the actual API error.
## Solution
Implemented comprehensive rate limit error handling across the stack:
### 1. Enhanced Error Classification (libs/utils)
Added new error type and detection functions:
- **New error type**: `'rate_limit'` added to `ErrorType` union
- **`isRateLimitError()`**: Detects 429 and rate_limit errors
- **`extractRetryAfter()`**: Extracts retry duration from error messages
- **Updated `classifyError()`**: Includes rate limit classification with retry-after metadata
- **Updated `getUserFriendlyErrorMessage()`**: Provides clear, actionable messages for rate limit errors
### 2. Improved Claude Provider Error Handling (apps/server)
Enhanced `ClaudeProvider.executeQuery()` to:
- Classify all errors using the enhanced error utilities
- Detect rate limit errors specifically
- Provide user-friendly error messages with:
- Clear explanation of the problem (rate limit exceeded)
- Retry-after duration when available
- Actionable tip: reduce `maxConcurrency` in auto-mode
- Preserve original error details for debugging
### 3. Comprehensive Test Coverage
Added 8 new tests covering:
- Rate limit error detection (429, rate_limit keywords)
- Retry-after extraction from various message formats
- Error classification with retry metadata
- User-friendly message generation
- Edge cases (null/undefined, non-rate-limit errors)
**Total test suite**: 162 tests passing ✅
## User-Facing Changes
### Before
```
[AutoMode] Feature touch-gesture-support failed: Error: Claude Code process exited with code 1
```
### After
```
[AutoMode] Feature touch-gesture-support failed: Rate limit exceeded (429). Please wait 60 seconds before retrying.
Tip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.
```
## Benefits
1. **Clear communication**: Users understand exactly what went wrong
2. **Actionable guidance**: Users know how long to wait and how to prevent future errors
3. **Better debugging**: Original error details preserved for technical investigation
4. **Type safety**: New `isRateLimit` and `retryAfter` fields properly typed in `ErrorInfo`
5. **Comprehensive testing**: All edge cases covered with automated tests
## Technical Details
### Files Modified
- `libs/types/src/error.ts` - Added `'rate_limit'` type and `retryAfter` field
- `libs/utils/src/error-handler.ts` - Added rate limit detection and extraction logic
- `libs/utils/src/index.ts` - Exported new utility functions
- `libs/utils/tests/error-handler.test.ts` - Added 8 new test cases
- `apps/server/src/providers/claude-provider.ts` - Enhanced error handling with user-friendly messages
### API Changes
**ErrorInfo interface** (backwards compatible):
```typescript
interface ErrorInfo {
type: ErrorType; // Now includes 'rate_limit'
message: string;
isAbort: boolean;
isAuth: boolean;
isCancellation: boolean;
isRateLimit: boolean; // NEW
retryAfter?: number; // NEW (seconds to wait)
originalError: unknown;
}
```
**New utility functions**:
```typescript
isRateLimitError(error: unknown): boolean
extractRetryAfter(error: unknown): number | undefined
```
## Future Improvements
This PR lays the groundwork for future enhancements:
1. **Automatic retry with exponential backoff**: Use `retryAfter` to implement smart retry logic
2. **Global rate limiter**: Track requests to prevent hitting limits proactively
3. **Concurrency auto-adjustment**: Dynamically reduce concurrency when rate limits are detected
4. **User notifications**: Show toast/banner when rate limits are approaching
## Testing
Run tests with:
```bash
npm run test -w @automaker/utils
```
All 162 tests pass, including 8 new rate limit tests.

172
CLAUDE.md
View File

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

View File

@@ -1,685 +0,0 @@
# Contributing to Automaker
Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development.
Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone.
This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process.
## Contribution License Agreement
**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner.
**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return.
For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment).
## Table of Contents
- [Contributing to Automaker](#contributing-to-automaker)
- [Table of Contents](#table-of-contents)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Fork and Clone](#fork-and-clone)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Pull Request Process](#pull-request-process)
- [Branch Naming Convention](#branch-naming-convention)
- [Commit Message Format](#commit-message-format)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [1. Prepare Your Changes](#1-prepare-your-changes)
- [2. Run Pre-submission Checks](#2-run-pre-submission-checks)
- [3. Push Your Changes](#3-push-your-changes)
- [4. Open a Pull Request](#4-open-a-pull-request)
- [PR Requirements Checklist](#pr-requirements-checklist)
- [Review Process](#review-process)
- [What to Expect](#what-to-expect)
- [Review Focus Areas](#review-focus-areas)
- [Responding to Feedback](#responding-to-feedback)
- [Approval Criteria](#approval-criteria)
- [Getting Help](#getting-help)
- [Code Style Guidelines](#code-style-guidelines)
- [Testing Requirements](#testing-requirements)
- [Running Tests](#running-tests)
- [Test Frameworks](#test-frameworks)
- [End-to-End Tests (Playwright)](#end-to-end-tests-playwright)
- [Unit Tests (Vitest)](#unit-tests-vitest)
- [Writing Tests](#writing-tests)
- [When to Write Tests](#when-to-write-tests)
- [CI/CD Pipeline](#cicd-pipeline)
- [CI Checks](#ci-checks)
- [CI Testing Environment](#ci-testing-environment)
- [Viewing CI Results](#viewing-ci-results)
- [Common CI Failures](#common-ci-failures)
- [Coverage Requirements](#coverage-requirements)
- [Issue Reporting](#issue-reporting)
- [Bug Reports](#bug-reports)
- [Before Reporting](#before-reporting)
- [Bug Report Template](#bug-report-template)
- [Feature Requests](#feature-requests)
- [Before Requesting](#before-requesting)
- [Feature Request Template](#feature-request-template)
- [Security Issues](#security-issues)
---
## Getting Started
### Prerequisites
Before contributing to Automaker, ensure you have the following installed on your system:
- **Node.js 18+** (tested with Node.js 22)
- Download from [nodejs.org](https://nodejs.org/)
- Verify installation: `node --version`
- **npm** (comes with Node.js)
- Verify installation: `npm --version`
- **Git** for version control
- Verify installation: `git --version`
- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality)
- Required to run the AI development features
**Optional but recommended:**
- A code editor with TypeScript support (VS Code recommended)
- GitHub CLI (`gh`) for easier PR management
### Fork and Clone
1. **Fork the repository** on GitHub
- Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker)
- Click the "Fork" button in the top-right corner
- This creates your own copy of the repository
2. **Clone your fork locally**
```bash
git clone https://github.com/YOUR_USERNAME/automaker.git
cd automaker
```
3. **Add the upstream remote** to keep your fork in sync
```bash
git remote add upstream https://github.com/AutoMaker-Org/automaker.git
```
4. **Verify remotes**
```bash
git remote -v
# Should show:
# origin https://github.com/YOUR_USERNAME/automaker.git (fetch)
# origin https://github.com/YOUR_USERNAME/automaker.git (push)
# upstream https://github.com/AutoMaker-Org/automaker.git (fetch)
# upstream https://github.com/AutoMaker-Org/automaker.git (push)
```
### Development Setup
1. **Install dependencies**
```bash
npm install
```
2. **Build shared packages** (required before running the app)
```bash
npm run build:packages
```
3. **Start the development server**
```bash
npm run dev # Interactive launcher - choose mode
npm run dev:web # Browser mode (web interface)
npm run dev:electron # Desktop app mode
```
**Common development commands:**
| Command | Description |
| ------------------------ | -------------------------------- |
| `npm run dev` | Interactive development launcher |
| `npm run dev:web` | Start in browser mode |
| `npm run dev:electron` | Start desktop app |
| `npm run build` | Build all packages and apps |
| `npm run build:packages` | Build shared packages only |
| `npm run lint` | Run ESLint checks |
| `npm run format` | Format code with Prettier |
| `npm run format:check` | Check formatting without changes |
| `npm run test` | Run E2E tests (Playwright) |
| `npm run test:server` | Run server unit tests |
| `npm run test:packages` | Run package tests |
| `npm run test:all` | Run all tests |
### Project Structure
Automaker is organized as an npm workspace monorepo:
```
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
├── libs/
│ ├── @automaker/types/ # Shared TypeScript types
│ ├── @automaker/utils/ # Utility functions
│ ├── @automaker/prompts/ # AI prompt templates
│ ├── @automaker/platform/ # Platform abstractions
│ ├── @automaker/model-resolver/ # AI model resolution
│ ├── @automaker/dependency-resolver/ # Dependency management
│ └── @automaker/git-utils/ # Git operations
├── docs/ # Documentation
└── package.json # Root package configuration
```
**Key conventions:**
- Always import from `@automaker/*` shared packages, never use relative paths to `libs/`
- Frontend code lives in `apps/ui/`
- Backend code lives in `apps/server/`
- Shared logic should be in the appropriate `libs/` package
---
## Pull Request Process
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
### Branch Naming Convention
We use a consistent branch naming pattern to keep our repository organized:
```
<type>/<description>
```
**Branch types:**
| Type | Purpose | Example |
| ---------- | ------------------------ | --------------------------------- |
| `feature` | New functionality | `feature/add-user-authentication` |
| `fix` | Bug fixes | `fix/resolve-memory-leak` |
| `docs` | Documentation changes | `docs/update-contributing-guide` |
| `refactor` | Code restructuring | `refactor/simplify-api-handlers` |
| `test` | Adding or updating tests | `test/add-utils-unit-tests` |
| `chore` | Maintenance tasks | `chore/update-dependencies` |
**Guidelines:**
- Use lowercase letters and hyphens (no underscores or spaces)
- Keep descriptions short but descriptive
- Include issue number when applicable: `feature/123-add-login`
```bash
# Create and checkout a new feature branch
git checkout -b feature/add-dark-mode
# Create a fix branch with issue reference
git checkout -b fix/456-resolve-login-error
```
### Commit Message Format
We follow the **Conventional Commits** style for clear, readable commit history:
```
<type>: <description>
[optional body]
```
**Commit types:**
| Type | Purpose |
| ---------- | --------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Formatting (no code change) |
| `refactor` | Code restructuring |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks |
**Guidelines:**
- Use **imperative mood** ("Add feature" not "Added feature")
- Keep first line under **72 characters**
- Capitalize the first letter after the type prefix
- No period at the end of the subject line
- Add a blank line before the body for detailed explanations
**Examples:**
```bash
# Simple commit
git commit -m "feat: Add user authentication flow"
# Commit with body for more context
git commit -m "fix: Resolve memory leak in WebSocket handler
The connection cleanup was not being called when clients
disconnected unexpectedly. Added proper cleanup in the
error handler to prevent memory accumulation."
# Documentation update
git commit -m "docs: Update API documentation"
# Refactoring
git commit -m "refactor: Simplify state management logic"
```
### Submitting a Pull Request
Follow these steps to submit your contribution:
#### 1. Prepare Your Changes
Ensure you've synced with the latest upstream changes:
```bash
# Fetch latest changes from upstream
git fetch upstream
# Rebase your branch on main (if needed)
git rebase upstream/main
```
#### 2. Run Pre-submission Checks
Before opening your PR, verify everything passes locally:
```bash
# Run all tests
npm run test:all
# Check formatting
npm run format:check
# Run linter
npm run lint
# Build to verify no compile errors
npm run build
```
#### 3. Push Your Changes
```bash
# Push your branch to your fork
git push origin feature/your-feature-name
```
#### 4. Open a Pull Request
1. Go to your fork on GitHub
2. Click "Compare & pull request" for your branch
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main`
4. Fill out the PR template completely
#### PR Requirements Checklist
Your PR should include:
- [ ] **Clear title** describing the change (use conventional commit format)
- [ ] **Description** explaining what changed and why
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
- [ ] **All CI checks passing** (format, lint, build, tests)
- [ ] **No merge conflicts** with main branch
- [ ] **Tests included** for new functionality
- [ ] **Documentation updated** if adding/changing public APIs
**Example PR Description:**
```markdown
## Summary
This PR adds dark mode support to the Automaker UI.
- Implements theme toggle in settings panel
- Adds CSS custom properties for theme colors
- Persists theme preference to localStorage
## Related Issue
Closes #123
## Testing
- [x] Tested toggle functionality in Chrome and Firefox
- [x] Verified theme persists across page reloads
- [x] Checked accessibility contrast ratios
## Screenshots
[Include before/after screenshots for UI changes]
```
### Review Process
All contributions go through code review to maintain quality:
#### What to Expect
1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review
2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it
3. **Feedback & Discussion** - The reviewer may ask questions or request changes
4. **Iteration** - Make requested changes and push updates to the same branch
5. **Approval & Merge** - Once approved and checks pass, your PR will be merged
#### Review Focus Areas
The reviewer checks for:
- **Correctness** - Does the code work as intended?
- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)?
- **Test Coverage** - Are new features properly tested?
- **Documentation** - Are public APIs documented?
- **Breaking Changes** - Are any breaking changes discussed first?
#### Responding to Feedback
- Respond to **all** review comments, even if just to acknowledge
- Ask questions if feedback is unclear
- Push additional commits to address feedback (don't force-push during review)
- Mark conversations as resolved once addressed
#### Approval Criteria
Your PR is ready to merge when:
- ✅ All CI checks pass
- ✅ The maintainer has approved the changes
- ✅ All review comments are addressed
- ✅ No unresolved merge conflicts
#### Getting Help
If your PR seems stuck:
- Comment asking for status update (mention @webdevcody if needed)
- Reach out on [Discord](https://discord.gg/jjem7aEDKU)
- Make sure all checks are passing and you've responded to all feedback
---
## Code Style Guidelines
Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing.
---
## Testing Requirements
Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests.
### Running Tests
Use these commands to run tests locally:
| Command | Description |
| ------------------------------ | ------------------------------------- |
| `npm run test` | Run E2E tests (Playwright) |
| `npm run test:server` | Run server unit tests (Vitest) |
| `npm run test:packages` | Run shared package tests |
| `npm run test:all` | Run all tests |
| `npm run test:server:coverage` | Run server tests with coverage report |
**Before submitting a PR**, always run the full test suite:
```bash
npm run test:all
```
### Test Frameworks
#### End-to-End Tests (Playwright)
E2E tests verify the entire application works correctly from a user's perspective.
- **Framework:** [Playwright](https://playwright.dev/)
- **Location:** `e2e/` directory
- **Test ports:** UI on port 3007, Server on port 3008
**Running E2E tests:**
```bash
# Run all E2E tests
npm run test
# Run with headed browser (useful for debugging)
npx playwright test --headed
# Run a specific test file
npm test --workspace=@automaker/ui -- tests/example.spec.ts
```
**E2E Test Guidelines:**
- Write tests from a user's perspective
- Use descriptive test names that explain the scenario
- Clean up test data after each test
- Use appropriate timeouts for async operations
- Prefer `locator` over direct selectors for resilience
#### Unit Tests (Vitest)
Unit tests verify individual functions and modules work correctly in isolation.
- **Framework:** [Vitest](https://vitest.dev/)
- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`)
**Running unit tests:**
```bash
# Run all server unit tests
npm run test:server
# Run with coverage report
npm run test:server:coverage
# Run package tests
npm run test:packages
# Run in watch mode during development
npx vitest --watch
```
**Unit Test Guidelines:**
- Keep tests small and focused on one behavior
- Use descriptive test names: `it('should return null when user is not found')`
- Follow the AAA pattern: Arrange, Act, Assert
- Mock external dependencies to isolate the unit under test
- Aim for meaningful coverage, not just line coverage
### Writing Tests
#### When to Write Tests
- **New features:** All new features should include tests
- **Bug fixes:** Add a test that reproduces the bug before fixing
- **Refactoring:** Ensure existing tests pass after refactoring
- **Public APIs:** All public APIs must have test coverage
### CI/CD Pipeline
Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks.
#### CI Checks
The following checks must pass before your PR can be merged:
| Check | Description |
| ----------------- | --------------------------------------------- |
| **Format** | Verifies code is formatted with Prettier |
| **Build** | Ensures the project compiles without errors |
| **Package Tests** | Runs tests for shared `@automaker/*` packages |
| **Server Tests** | Runs server unit tests with coverage |
#### CI Testing Environment
For CI environments, Automaker supports a mock agent mode:
```bash
# Enable mock agent mode for CI testing
AUTOMAKER_MOCK_AGENT=true npm run test
```
This allows tests to run without requiring a real Claude API connection.
#### Viewing CI Results
1. Go to your PR on GitHub
2. Scroll to the "Checks" section at the bottom
3. Click on any failed check to see detailed logs
4. Fix issues locally and push updates
#### Common CI Failures
| Issue | Solution |
| ------------------- | --------------------------------------------- |
| Format check failed | Run `npm run format` locally |
| Build failed | Run `npm run build` and fix TypeScript errors |
| Tests failed | Run `npm run test:all` locally to reproduce |
| Coverage decreased | Add tests for new code paths |
### Coverage Requirements
While we don't enforce strict coverage percentages, we expect:
- **New features:** Should include comprehensive tests
- **Bug fixes:** Should include a regression test
- **Critical paths:** Must have test coverage (authentication, data persistence, etc.)
To view coverage reports locally:
```bash
npm run test:server:coverage
```
This generates an HTML report you can open in your browser to see which lines are covered.
---
## Issue Reporting
Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively.
### Bug Reports
When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue.
#### Before Reporting
1. **Search existing issues** - Check if the bug has already been reported
2. **Try the latest version** - Make sure you're running the latest version of Automaker
3. **Reproduce the issue** - Verify you can consistently reproduce the bug
#### Bug Report Template
When creating a bug report, include:
- **Title:** A clear, descriptive title summarizing the issue
- **Environment:**
- Operating System and version
- Node.js version (`node --version`)
- Automaker version or commit hash
- **Steps to Reproduce:** Numbered list of steps to reproduce the bug
- **Expected Behavior:** What you expected to happen
- **Actual Behavior:** What actually happened
- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots
**Example Bug Report:**
```markdown
## Bug: WebSocket connection drops after 5 minutes of inactivity
### Environment
- OS: Windows 11
- Node.js: 22.11.0
- Automaker: commit abc1234
### Steps to Reproduce
1. Start the application with `npm run dev:web`
2. Open the Kanban board
3. Leave the browser tab open for 5+ minutes without interaction
4. Try to move a card
### Expected Behavior
The card should move to the new column.
### Actual Behavior
The UI shows "Connection lost" and the card doesn't move.
### Logs
[WebSocket] Connection closed: 1006
```
### Feature Requests
We welcome ideas for improving Automaker! Here's how to submit a feature request:
#### Before Requesting
1. **Check existing issues** - Your idea may already be proposed or in development
2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio
#### Feature Request Template
A good feature request includes:
- **Title:** A brief, descriptive title
- **Problem Statement:** What problem does this feature solve?
- **Proposed Solution:** How do you envision this working?
- **Alternatives Considered:** What other approaches did you consider?
- **Additional Context:** Mockups, examples, or references that help explain your idea
**Example Feature Request:**
```markdown
## Feature: Dark Mode Support
### Problem Statement
Working late at night, the bright UI causes eye strain and doesn't match
my system's dark theme preference.
### Proposed Solution
Add a theme toggle in the settings panel that allows switching between
light and dark modes. Ideally, it should also detect system preference.
### Alternatives Considered
- Browser extension to force dark mode (doesn't work well with custom styling)
- Custom CSS override (breaks with updates)
### Additional Context
Similar to how VS Code handles themes - a dropdown in settings with
immediate preview.
```
### Security Issues
**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead:
1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody`
2. Include detailed steps to reproduce
3. Allow time for us to address the issue before public disclosure
We take security seriously and appreciate responsible disclosure.
---
For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details.
---
Thank you for contributing to Automaker!

View File

@@ -1,154 +0,0 @@
# Automaker Multi-Stage Dockerfile
# Single Dockerfile for both server and UI builds
# Usage:
# docker build --target server -t automaker-server .
# docker build --target ui -t automaker-ui .
# Or use docker-compose which selects targets automatically
# =============================================================================
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
# =============================================================================
FROM node:22-alpine AS base
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy root package files
COPY package*.json ./
# Copy all libs package.json files (centralized - add new libs here)
COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/
# Copy scripts (needed by npm workspace)
COPY scripts ./scripts
# =============================================================================
# SERVER BUILD STAGE
# =============================================================================
FROM base AS server-builder
# Copy server-specific package.json
COPY apps/server/package*.json ./apps/server/
# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules)
RUN npm ci --ignore-scripts && npm rebuild node-pty
# Copy all source files
COPY libs ./libs
COPY apps/server ./apps/server
# Build packages in dependency order, then build server
RUN npm run build:packages && npm run build --workspace=apps/server
# =============================================================================
# SERVER PRODUCTION STAGE
# =============================================================================
FROM node:22-alpine AS server
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
RUN apk add --no-cache git curl bash && \
GH_VERSION="2.63.2" && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) GH_ARCH="amd64" ;; \
aarch64|arm64) GH_ARCH="arm64" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
# Install Claude CLI globally
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Copy root package.json (needed for workspace resolution)
COPY --from=server-builder /app/package*.json ./
# Copy built libs (workspace packages are symlinked in node_modules)
COPY --from=server-builder /app/libs ./libs
# Copy built server
COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
# Copy node_modules (includes symlinks to libs)
COPY --from=server-builder /app/node_modules ./node_modules
# Create data and projects directories
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
# Configure git for mounted volumes and authentication
# Use --system so it's not overwritten by mounted user .gitconfig
RUN git config --system --add safe.directory '*' && \
# Use gh as credential helper (works with GH_TOKEN env var)
git config --system credential.helper '!gh auth git-credential'
# Switch to non-root user
USER automaker
# Environment variables
ENV PORT=3008
ENV DATA_DIR=/data
# Expose port
EXPOSE 3008
# Health check (using curl since it's already installed, more reliable than busybox wget)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3008/api/health || exit 1
# Start server
CMD ["node", "apps/server/dist/index.js"]
# =============================================================================
# UI BUILD STAGE
# =============================================================================
FROM base AS ui-builder
# Copy UI-specific package.json
COPY apps/ui/package*.json ./apps/ui/
# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script)
RUN npm ci --ignore-scripts
# Copy all source files
COPY libs ./libs
COPY apps/ui ./apps/ui
# Build packages in dependency order, then build UI
# VITE_SERVER_URL tells the UI where to find the API server
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=http://localhost:3008
ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build:packages && npm run build --workspace=apps/ui
# =============================================================================
# UI PRODUCTION STAGE
# =============================================================================
FROM nginx:alpine AS ui
# Copy built files
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
# Copy nginx config for SPA routing
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

518
README.md
View File

@@ -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.
![Automaker UI](https://i.imgur.com/jdwKydM.png)
@@ -73,9 +59,9 @@ Traditional development tools help you write code. Automaker helps you **orchest
4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Agent SDK
### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters
@@ -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://code.claude.com/docs/en/overview) 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,162 +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
# Build Electron app for distribution
npm run build:electron
```
### Running Production Build
```bash
# Start production Next.js server
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
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
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
```bash
# Build and run with Docker Compose
docker-compose up -d
# Access UI at http://localhost:3007
# API at http://localhost:3008
# View logs
docker-compose logs -f
# Stop containers
docker-compose down
```
##### Configuration
Create a `.env` file in the project root if using API key authentication:
```bash
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
ANTHROPIC_API_KEY=sk-ant-...
```
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
##### Working with Projects (Host Directory Access)
By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored):
```yaml
services:
server:
volumes:
# Mount your project directories
- /path/to/your/project:/projects/your-project
```
##### Claude CLI Authentication (Optional)
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
```yaml
services:
server:
volumes:
# Linux/macOS
- ~/.claude:/home/automaker/.claude
# Windows
- C:/Users/YourName/.claude:/home/automaker/.claude
```
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
##### GitHub CLI Authentication (For Git Push/PR Operations)
To enable git push and GitHub CLI operations inside the container:
```yaml
services:
server:
volumes:
# Mount GitHub CLI config
# Linux/macOS
- ~/.config/gh:/home/automaker/.config/gh
# Windows
- 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh'
# Mount git config for user identity (name, email)
- ~/.gitconfig:/home/automaker/.gitconfig:ro
environment:
# GitHub token (required on Windows where tokens are in Credential Manager)
# Get your token with: gh auth token
- GH_TOKEN=${GH_TOKEN}
```
Then add `GH_TOKEN` to your `.env` file:
```bash
GH_TOKEN=gho_your_github_token_here
```
##### Complete docker-compose.override.yml Example
```yaml
services:
server:
volumes:
# Your projects
- /path/to/project1:/projects/project1
- /path/to/project2:/projects/project2
# Authentication configs
- ~/.claude:/home/automaker/.claude
- ~/.config/gh:/home/automaker/.config/gh
- ~/.gitconfig:/home/automaker/.gitconfig:ro
environment:
- GH_TOKEN=${GH_TOKEN}
```
##### Architecture Support
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
### 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
@@ -360,284 +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:
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community
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

File diff suppressed because it is too large Load Diff

View 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"
}
}

67
apps/server/Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# Automaker Backend Server
# Multi-stage build for minimal production image
# Build stage
FROM node:20-alpine AS builder
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files and scripts needed for postinstall
COPY package*.json ./
COPY apps/server/package*.json ./apps/server/
COPY scripts ./scripts
# Install dependencies
RUN npm ci --workspace=apps/server
# Copy source
COPY apps/server ./apps/server
# Build TypeScript
RUN npm run build --workspace=apps/server
# Production stage
FROM node:20-alpine
# Install git, curl, and GitHub CLI (pinned version for reproducible builds)
RUN apk add --no-cache git curl && \
GH_VERSION="2.63.2" && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv "gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh && \
rm -rf gh.tar.gz "gh_${GH_VERSION}_linux_amd64"
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Copy built files and production dependencies
COPY --from=builder /app/apps/server/dist ./dist
COPY --from=builder /app/apps/server/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# Create data directory
RUN mkdir -p /data && chown automaker:automaker /data
# Switch to non-root user
USER automaker
# Environment variables
ENV NODE_ENV=production
ENV PORT=3008
ENV DATA_DIR=/data
# Expose port
EXPOSE 3008
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
# Start server
CMD ["node", "dist/index.js"]

View File

@@ -9,7 +9,6 @@
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:test": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
@@ -29,8 +28,6 @@
"@automaker/prompts": "^1.0.0",
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
@@ -39,8 +36,6 @@
"ws": "^8.18.3"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/morgan": "^1.9.10",

View File

@@ -9,19 +9,15 @@
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import cookie from 'cookie';
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import dotenv from 'dotenv';
import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths } from '@automaker/platform';
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
import { requireJsonContentType } from './middleware/require-json-content-type.js';
import { createAuthRoutes } from './routes/auth/index.js';
import { authMiddleware, getAuthStatus } from './lib/auth.js';
import { createFsRoutes } from './routes/fs/index.js';
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
import { createHealthRoutes } from './routes/health/index.js';
import { createAgentRoutes } from './routes/agent/index.js';
import { createSessionsRoutes } from './routes/sessions/index.js';
import { createFeaturesRoutes } from './routes/features/index.js';
@@ -52,12 +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';
import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
// Load environment variables
dotenv.config();
@@ -95,7 +85,7 @@ const app = express();
// Middleware
// Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var)
if (ENABLE_REQUEST_LOGGING) {
morgan.token('status-colored', (_req, res) => {
morgan.token('status-colored', (req, res) => {
const status = res.statusCode;
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
@@ -109,55 +99,23 @@ if (ENABLE_REQUEST_LOGGING) {
})
);
}
// CORS configuration
// When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps, curl, Electron)
if (!origin) {
callback(null, true);
return;
}
// If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(new Error('Not allowed by CORS'));
}
return;
}
// For local development, allow localhost origins
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
callback(null, origin);
return;
}
// Reject other origins by default for security
callback(new Error('Not allowed by CORS'));
},
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
})
);
app.use(express.json({ limit: '50mb' }));
app.use(cookieParser());
// Create shared event emitter for streaming
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();
const mcpTestService = new MCPTestService(settingsService);
// Initialize services
(async () => {
@@ -165,52 +123,32 @@ const mcpTestService = new MCPTestService(settingsService);
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);
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
// This helps prevent CSRF and content-type confusion attacks
app.use('/api', requireJsonContentType);
// Mount API routes - health and auth are unauthenticated
// Mount API routes - health is unauthenticated for monitoring
app.use('/api/health', createHealthRoutes());
app.use('/api/auth', createAuthRoutes());
// Apply authentication to all other routes
app.use('/api', authMiddleware);
// Protected health endpoint with detailed info
app.get('/api/health/detailed', createDetailedHandler());
app.use('/api/fs', createFsRoutes(events));
app.use('/api/agent', createAgentRoutes(agentService, events));
app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
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/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/github', createGitHubRoutes());
app.use('/api/context', createContextRoutes());
// Create HTTP server
const server = createServer(app);
@@ -220,55 +158,10 @@ const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
/**
* Authenticate WebSocket upgrade requests
* Checks for API key in header/query, session token in header/query, OR valid session cookie
*/
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
const url = new URL(request.url || '', `http://${request.headers.host}`);
// Convert URL search params to query object
const query: Record<string, string | undefined> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
// Parse cookies from header
const cookieHeader = request.headers.cookie;
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
// Use shared authentication logic for standard auth methods
if (
checkRawAuthentication(
request.headers as Record<string, string | string[] | undefined>,
query,
cookies
)
) {
return true;
}
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
const wsToken = url.searchParams.get('wsToken');
if (wsToken && validateWsConnectionToken(wsToken)) {
return true;
}
return false;
}
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on('upgrade', (request, socket, head) => {
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
// Authenticate all WebSocket connections
if (!authenticateWebSocket(request)) {
console.log('[WebSocket] Authentication failed, rejecting connection');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (pathname === '/api/events') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
@@ -284,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 }));
}
});
@@ -318,7 +192,7 @@ wss.on('connection', (ws: WebSocket) => {
});
ws.on('error', (error) => {
console.error('[WebSocket] ERROR:', error);
console.error('[WebSocket] Error:', error);
unsubscribe();
});
});

View File

@@ -1,378 +1,54 @@
/**
* Authentication middleware for API security
*
* Supports two authentication methods:
* 1. Header-based (X-API-Key) - Used by Electron mode
* 2. Cookie-based (HTTP-only session cookie) - Used by web mode
*
* Auto-generates an API key on first run if none is configured.
* Supports API key authentication via header or environment variable.
*/
import type { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
const DATA_DIR = process.env.DATA_DIR || './data';
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
const SESSIONS_FILE = path.join(DATA_DIR, '.sessions');
const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
// Session store - persisted to file for survival across server restarts
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
// Clean up expired WebSocket tokens periodically
setInterval(() => {
const now = Date.now();
wsConnectionTokens.forEach((data, token) => {
if (data.expiresAt <= now) {
wsConnectionTokens.delete(token);
}
});
}, 60 * 1000); // Clean up every minute
/**
* Load sessions from file on startup
*/
function loadSessions(): void {
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
const sessions = JSON.parse(data) as Array<
[string, { createdAt: number; expiresAt: number }]
>;
const now = Date.now();
let loadedCount = 0;
let expiredCount = 0;
for (const [token, session] of sessions) {
// Only load non-expired sessions
if (session.expiresAt > now) {
validSessions.set(token, session);
loadedCount++;
} else {
expiredCount++;
}
}
if (loadedCount > 0 || expiredCount > 0) {
console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`);
}
}
} catch (error) {
console.warn('[Auth] Error loading sessions:', error);
}
}
/**
* Save sessions to file (async)
*/
async function saveSessions(): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
encoding: 'utf-8',
mode: 0o600,
});
} catch (error) {
console.error('[Auth] Failed to save sessions:', error);
}
}
// Load existing sessions on startup
loadSessions();
/**
* Ensure an API key exists - either from env var, file, or generate new one.
* This provides CSRF protection by requiring a secret key for all API requests.
*/
function ensureApiKey(): string {
// First check environment variable (Electron passes it this way)
if (process.env.AUTOMAKER_API_KEY) {
console.log('[Auth] Using API key from environment variable');
return process.env.AUTOMAKER_API_KEY;
}
// Try to read from file
try {
if (fs.existsSync(API_KEY_FILE)) {
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
if (key) {
console.log('[Auth] Loaded API key from file');
return key;
}
}
} catch (error) {
console.warn('[Auth] Error reading API key file:', error);
}
// Generate new key
const newKey = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Auth] Generated new API key');
} catch (error) {
console.error('[Auth] Failed to save API key:', error);
}
return newKey;
}
// API key - always generated/loaded on startup for CSRF protection
const API_KEY = ensureApiKey();
// Print API key to console for web mode users (unless suppressed for production logging)
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
console.log(`
╔═══════════════════════════════════════════════════════════════════════╗
║ 🔐 API Key for Web Mode Authentication ║
╠═══════════════════════════════════════════════════════════════════════╣
║ ║
║ When accessing via browser, you'll be prompted to enter this key: ║
║ ║
${API_KEY}
║ ║
║ In Electron mode, authentication is handled automatically. ║
╚═══════════════════════════════════════════════════════════════════════╝
`);
} else {
console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
}
/**
* Generate a cryptographically secure session token
*/
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Create a new session and return the token
*/
export async function createSession(): Promise<string> {
const token = generateSessionToken();
const now = Date.now();
validSessions.set(token, {
createdAt: now,
expiresAt: now + SESSION_MAX_AGE_MS,
});
await saveSessions(); // Persist to file
return token;
}
/**
* Validate a session token
* Note: This returns synchronously but triggers async persistence if session expired
*/
export function validateSession(token: string): boolean {
const session = validSessions.get(token);
if (!session) return false;
if (Date.now() > session.expiresAt) {
validSessions.delete(token);
// Fire-and-forget: persist removal asynchronously
saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err));
return false;
}
return true;
}
/**
* Invalidate a session token
*/
export async function invalidateSession(token: string): Promise<void> {
validSessions.delete(token);
await saveSessions(); // Persist removal
}
/**
* Create a short-lived WebSocket connection token
* Used for initial WebSocket handshake authentication
*/
export function createWsConnectionToken(): string {
const token = generateSessionToken();
const now = Date.now();
wsConnectionTokens.set(token, {
createdAt: now,
expiresAt: now + WS_TOKEN_MAX_AGE_MS,
});
return token;
}
/**
* Validate a WebSocket connection token
* These tokens are single-use and short-lived (5 minutes)
* Token is invalidated immediately after first successful use
*/
export function validateWsConnectionToken(token: string): boolean {
const tokenData = wsConnectionTokens.get(token);
if (!tokenData) return false;
// Always delete the token (single-use)
wsConnectionTokens.delete(token);
// Check if expired
if (Date.now() > tokenData.expiresAt) {
return false;
}
return true;
}
/**
* Validate the API key using timing-safe comparison
* Prevents timing attacks that could leak information about the key
*/
export function validateApiKey(key: string): boolean {
if (!key || typeof key !== 'string') return false;
// Both buffers must be the same length for timingSafeEqual
const keyBuffer = Buffer.from(key);
const apiKeyBuffer = Buffer.from(API_KEY);
// If lengths differ, compare against a dummy to maintain constant time
if (keyBuffer.length !== apiKeyBuffer.length) {
crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer);
return false;
}
return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer);
}
/**
* Get session cookie options
*/
export function getSessionCookieOptions(): {
httpOnly: boolean;
secure: boolean;
sameSite: 'strict' | 'lax' | 'none';
maxAge: number;
path: string;
} {
return {
httpOnly: true, // JavaScript cannot access this cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
maxAge: SESSION_MAX_AGE_MS,
path: '/',
};
}
/**
* Get the session cookie name
*/
export function getSessionCookieName(): string {
return SESSION_COOKIE_NAME;
}
/**
* Authentication result type
*/
type AuthResult =
| { authenticated: true }
| { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' };
/**
* Core authentication check - shared between middleware and status check
* Extracts auth credentials from various sources and validates them
*/
function checkAuthentication(
headers: Record<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): AuthResult {
// Check for API key in header (Electron mode)
const headerKey = headers['x-api-key'] as string | undefined;
if (headerKey) {
if (validateApiKey(headerKey)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session token in header (web mode with explicit token)
const sessionTokenHeader = headers['x-session-token'] as string | undefined;
if (sessionTokenHeader) {
if (validateSession(sessionTokenHeader)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_session' };
}
// Check for API key in query parameter (fallback)
const queryKey = query.apiKey;
if (queryKey) {
if (validateApiKey(queryKey)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'invalid_api_key' };
}
// Check for session cookie (web mode)
const sessionToken = cookies[SESSION_COOKIE_NAME];
if (sessionToken && validateSession(sessionToken)) {
return { authenticated: true };
}
return { authenticated: false, errorType: 'no_auth' };
}
// API key from environment (optional - if not set, auth is disabled)
const API_KEY = process.env.AUTOMAKER_API_KEY;
/**
* Authentication middleware
*
* Accepts either:
* 1. X-API-Key header (for Electron mode)
* 2. X-Session-Token header (for web mode with explicit token)
* 3. apiKey query parameter (fallback for cases where headers can't be set)
* 4. Session cookie (for web mode)
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
* If not set, allows all requests (development mode).
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
if (result.authenticated) {
// If no API key is configured, allow all requests
if (!API_KEY) {
next();
return;
}
// Return appropriate error based on what failed
switch (result.errorType) {
case 'invalid_api_key':
res.status(403).json({
success: false,
error: 'Invalid API key.',
});
break;
case 'invalid_session':
res.status(403).json({
success: false,
error: 'Invalid or expired session token.',
});
break;
case 'no_auth':
default:
res.status(401).json({
success: false,
error: 'Authentication required.',
});
// Check for API key in header
const providedKey = req.headers['x-api-key'] as string | undefined;
if (!providedKey) {
res.status(401).json({
success: false,
error: 'Authentication required. Provide X-API-Key header.',
});
return;
}
if (providedKey !== API_KEY) {
res.status(403).json({
success: false,
error: 'Invalid API key.',
});
return;
}
next();
}
/**
* Check if authentication is enabled (always true now)
* Check if authentication is enabled
*/
export function isAuthEnabled(): boolean {
return true;
return !!API_KEY;
}
/**
@@ -380,31 +56,7 @@ export function isAuthEnabled(): boolean {
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
return {
enabled: true,
method: 'api_key_or_session',
enabled: !!API_KEY,
method: API_KEY ? 'api_key' : 'none',
};
}
/**
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
return result.authenticated;
}
/**
* Check if raw credentials are authenticated
* Used for WebSocket authentication where we don't have Express request objects
*/
export function checkRawAuthentication(
headers: Record<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -1,50 +0,0 @@
/**
* Middleware to enforce Content-Type: application/json for request bodies
*
* This security middleware prevents malicious requests by requiring proper
* Content-Type headers for all POST, PUT, and PATCH requests.
*
* Rejecting requests without proper Content-Type helps prevent:
* - CSRF attacks via form submissions (which use application/x-www-form-urlencoded)
* - Content-type confusion attacks
* - Malformed request exploitation
*/
import type { Request, Response, NextFunction } from 'express';
// HTTP methods that typically include request bodies
const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH'];
/**
* Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests
*
* Returns 415 Unsupported Media Type if:
* - The request method is POST, PUT, or PATCH
* - AND the Content-Type header is missing or not application/json
*
* Allows requests to pass through if:
* - The request method is GET, DELETE, OPTIONS, HEAD, etc.
* - OR the Content-Type is properly set to application/json (with optional charset)
*/
export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void {
// Skip validation for methods that don't require a body
if (!METHODS_REQUIRING_JSON.includes(req.method)) {
next();
return;
}
const contentType = req.headers['content-type'];
// Check if Content-Type header exists and contains application/json
// Allows for charset parameter: "application/json; charset=utf-8"
if (!contentType || !contentType.toLowerCase().includes('application/json')) {
res.status(415).json({
success: false,
error: 'Unsupported Media Type',
message: 'Content-Type header must be application/json',
});
return;
}
next();
}

View File

@@ -7,7 +7,6 @@
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
import type {
ExecuteOptions,
ProviderMessage,
@@ -37,44 +36,25 @@ export class ClaudeProvider extends BaseProvider {
} = options;
// Build Claude SDK options
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
// the provider is the final point where SDK options are constructed.
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
// Determine permission mode based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
const toolsToUse = allowedTools || defaultTools;
const sdkOptions: Options = {
model,
systemPrompt,
maxTurns,
cwd,
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
...(allowedTools && shouldRestrictTools && { allowedTools }),
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
// Required when using bypassPermissions mode
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
allowedTools: toolsToUse,
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 }),
// Forward MCP servers configuration
...(options.mcpServers && { mcpServers: options.mcpServers }),
};
// Build prompt payload
@@ -108,32 +88,8 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage;
}
} catch (error) {
// Enhance error with user-friendly message and classification
const errorInfo = classifyError(error);
const userMessage = getUserFriendlyErrorMessage(error);
console.error('[ClaudeProvider] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: (error as Error).stack,
});
// Build enhanced error message with additional guidance for rate limits
const message = errorInfo.isRateLimit
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
: userMessage;
const enhancedError = new Error(message);
(enhancedError as any).originalError = error;
(enhancedError as any).type = errorInfo.type;
if (errorInfo.isRateLimit) {
(enhancedError as any).retryAfter = errorInfo.retryAfter;
}
throw enhancedError;
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
throw error;
}
}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,16 +19,7 @@ export function createSendHandler(agentService: AgentService) {
model?: string;
};
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) });
}

View File

@@ -10,8 +10,6 @@ import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.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';
const logger = createLogger('SpecRegeneration');
@@ -21,8 +19,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,17 +91,9 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
projectPath: projectPath,
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[FeatureGeneration]'
);
const options = createFeatureGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
});
logger.debug('SDK Options:', JSON.stringify(options, null, 2));

View File

@@ -17,8 +17,6 @@ import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
import { logAuthStatus } from './common.js';
import { 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';
const logger = createLogger('SpecRegeneration');
@@ -29,8 +27,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,17 +83,9 @@ ${getStructuredSpecPromptInstruction()}`;
content: 'Starting spec generation...\n',
});
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[SpecRegeneration]'
);
const options = createSpecGenerationOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: specOutputSchema,
@@ -280,13 +269,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);

View File

@@ -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());

View File

@@ -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', {

View File

@@ -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');

View File

@@ -1,247 +0,0 @@
/**
* Auth routes - Login, logout, and status endpoints
*
* Security model:
* - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie
* - Electron mode: Uses X-API-Key header (handled automatically via IPC)
*
* The session cookie is:
* - HTTP-only: JavaScript cannot read it (protects against XSS)
* - SameSite=Strict: Only sent for same-site requests (protects against CSRF)
*
* Mounted at /api/auth in the main server (BEFORE auth middleware).
*/
import { Router } from 'express';
import type { Request } from 'express';
import {
validateApiKey,
createSession,
invalidateSession,
getSessionCookieOptions,
getSessionCookieName,
isRequestAuthenticated,
createWsConnectionToken,
} from '../../lib/auth.js';
// Rate limiting configuration
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window
const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window
// Check if we're in test mode - disable rate limiting for E2E tests
const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true';
// In-memory rate limit tracking (resets on server restart)
const loginAttempts = new Map<string, { count: number; windowStart: number }>();
// Clean up old rate limit entries periodically (every 5 minutes)
setInterval(
() => {
const now = Date.now();
loginAttempts.forEach((data, ip) => {
if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
loginAttempts.delete(ip);
}
});
},
5 * 60 * 1000
);
/**
* Get client IP address from request
* Handles X-Forwarded-For header for reverse proxy setups
*/
function getClientIp(req: Request): string {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
// X-Forwarded-For can be a comma-separated list; take the first (original client)
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0];
return forwardedIp.trim();
}
return req.ip || req.socket.remoteAddress || 'unknown';
}
/**
* Check if an IP is rate limited
* Returns { limited: boolean, retryAfter?: number }
*/
function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } {
const now = Date.now();
const attempt = loginAttempts.get(ip);
if (!attempt) {
return { limited: false };
}
// Check if window has expired
if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
loginAttempts.delete(ip);
return { limited: false };
}
// Check if over limit
if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) {
const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000);
return { limited: true, retryAfter };
}
return { limited: false };
}
/**
* Record a login attempt for rate limiting
*/
function recordLoginAttempt(ip: string): void {
const now = Date.now();
const attempt = loginAttempts.get(ip);
if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) {
// Start new window
loginAttempts.set(ip, { count: 1, windowStart: now });
} else {
// Increment existing window
attempt.count++;
}
}
/**
* Create auth routes
*
* @returns Express Router with auth endpoints
*/
export function createAuthRoutes(): Router {
const router = Router();
/**
* GET /api/auth/status
*
* Returns whether the current request is authenticated.
* Used by the UI to determine if login is needed.
*/
router.get('/status', (req, res) => {
const authenticated = isRequestAuthenticated(req);
res.json({
success: true,
authenticated,
required: true,
});
});
/**
* POST /api/auth/login
*
* Validates the API key and sets a session cookie.
* Body: { apiKey: string }
*
* Rate limited to 5 attempts per minute per IP to prevent brute force attacks.
*/
router.post('/login', async (req, res) => {
const clientIp = getClientIp(req);
// Skip rate limiting in test mode to allow parallel E2E tests
if (!isTestMode) {
// Check rate limit before processing
const rateLimit = checkRateLimit(clientIp);
if (rateLimit.limited) {
res.status(429).json({
success: false,
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter,
});
return;
}
}
const { apiKey } = req.body as { apiKey?: string };
if (!apiKey) {
res.status(400).json({
success: false,
error: 'API key is required.',
});
return;
}
// Record this attempt (only for actual API key validation attempts, skip in test mode)
if (!isTestMode) {
recordLoginAttempt(clientIp);
}
if (!validateApiKey(apiKey)) {
res.status(401).json({
success: false,
error: 'Invalid API key.',
});
return;
}
// Create session and set cookie
const sessionToken = await createSession();
const cookieOptions = getSessionCookieOptions();
const cookieName = getSessionCookieName();
res.cookie(cookieName, sessionToken, cookieOptions);
res.json({
success: true,
message: 'Logged in successfully.',
// Return token for explicit header-based auth (works around cross-origin cookie issues)
token: sessionToken,
});
});
/**
* GET /api/auth/token
*
* Generates a short-lived WebSocket connection token if the user has a valid session.
* This token is used for initial WebSocket handshake authentication and expires in 5 minutes.
* The token is NOT the session cookie value - it's a separate, short-lived token.
*/
router.get('/token', (req, res) => {
// Validate the session is still valid (via cookie, API key, or session token header)
if (!isRequestAuthenticated(req)) {
res.status(401).json({
success: false,
error: 'Authentication required.',
});
return;
}
// Generate a new short-lived WebSocket connection token
const wsToken = createWsConnectionToken();
res.json({
success: true,
token: wsToken,
expiresIn: 300, // 5 minutes in seconds
});
});
/**
* POST /api/auth/logout
*
* Clears the session cookie and invalidates the session.
*/
router.post('/logout', async (req, res) => {
const cookieName = getSessionCookieName();
const sessionToken = req.cookies?.[cookieName] as string | undefined;
if (sessionToken) {
await invalidateSession(sessionToken);
}
// Clear the cookie
res.clearCookie(cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
res.json({
success: true,
message: 'Logged out successfully.',
});
});
return router;
}

View File

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

View File

@@ -1,162 +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, getPromptCustomization } 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`,
});
// Load prompts from settings
const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]');
// Build the system prompt
const systemPrompt = prompts.backlogPlan.systemPrompt;
// Build the user prompt from template
const currentFeatures = formatFeaturesForPrompt(features);
const userPrompt = prompts.backlogPlan.userPromptTemplate
.replace('{{currentFeatures}}', currentFeatures)
.replace('{{userRequest}}', prompt);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: 'Generating plan with AI...',
});
// Get the model to use
const effectiveModel = model || 'sonnet';
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Get autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// Execute the query
const stream = provider.executeQuery({
prompt: userPrompt,
model: effectiveModel,
cwd: projectPath,
systemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
});
let responseText = '';
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
}
}
// Parse the response
const result = parsePlanResponse(responseText);
events.emit('backlog-plan:event', {
type: 'backlog_plan_complete',
result,
});
return result;
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error('[BacklogPlan] Generation failed:', errorMessage);
events.emit('backlog-plan:event', {
type: 'backlog_plan_error',
error: errorMessage,
});
throw error;
} finally {
setRunningState(false, null);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,19 +8,17 @@
import { Router } from 'express';
import { 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;
}

View File

@@ -17,8 +17,6 @@ import { PathNotAllowedError } from '@automaker/platform';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -74,12 +72,9 @@ async function extractTextFromStream(
/**
* 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;
@@ -170,13 +165,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
// 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({
@@ -184,7 +172,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});

View File

@@ -17,8 +17,6 @@ import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * 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');
@@ -228,12 +226,9 @@ async function extractTextFromStream(
* 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();
@@ -330,20 +325,12 @@ export function createDescribeImageHandler(
const cwd = path.dirname(actualPath);
logger.info(`[${requestId}] Using cwd=${cwd}`);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
cwd,
settingsService,
'[DescribeImage]'
);
// Use the same centralized option builder used across the server (validates cwd)
const sdkOptions = createCustomOptions({
cwd,
model: CLAUDE_MODEL_MAP.haiku,
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
});

View File

@@ -6,19 +6,17 @@
*/
import { Router } from 'express';
import type { SettingsService } from '../../services/settings-service.js';
import { createEnhanceHandler } from './routes/enhance.js';
/**
* Create the enhance-prompt router
*
* @param settingsService - Settings service for loading custom prompts
* @returns Express router with enhance-prompt endpoints
*/
export function createEnhancePromptRoutes(settingsService?: SettingsService): Router {
export function createEnhancePromptRoutes(): Router {
const router = Router();
router.post('/', createEnhanceHandler(settingsService));
router.post('/', createEnhanceHandler());
return router;
}

View File

@@ -10,9 +10,8 @@ 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 type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getSystemPrompt,
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
@@ -84,12 +83,9 @@ async function extractTextFromStream(
/**
* Create the enhance request handler
*
* @param settingsService - Optional settings service for loading custom prompts
* @returns Express request handler for text enhancement
*/
export function createEnhanceHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
export function createEnhanceHandler(): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
@@ -132,19 +128,8 @@ export function createEnhanceHandler(
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
// Load enhancement prompts from settings (merges custom + defaults)
const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]');
// Get the system prompt for this mode from merged prompts
const systemPromptMap: Record<EnhancementMode, string> = {
improve: prompts.enhancement.improveSystemPrompt,
technical: prompts.enhancement.technicalSystemPrompt,
simplify: prompts.enhancement.simplifySystemPrompt,
acceptance: prompts.enhancement.acceptanceSystemPrompt,
};
const systemPrompt = systemPromptMap[validMode];
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
// Get the system prompt for this mode
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

View File

@@ -3,56 +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 { createListCommentsHandler } from './routes/list-comments.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('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
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;
}

View File

@@ -1,212 +0,0 @@
/**
* POST /issue-comments endpoint - Fetch comments for a GitHub issue
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import type { GitHubComment, IssueCommentsResult } from '@automaker/types';
import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
interface ListCommentsRequest {
projectPath: string;
issueNumber: number;
cursor?: string;
}
interface GraphQLComment {
id: string;
author: {
login: string;
avatarUrl?: string;
} | null;
body: string;
createdAt: string;
updatedAt: string;
}
interface GraphQLResponse {
data?: {
repository?: {
issue?: {
comments: {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
};
};
};
};
errors?: Array<{ message: string }>;
}
/** Timeout for GitHub API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
/**
* Validate cursor format (GraphQL cursors are typically base64 strings)
*/
function isValidCursor(cursor: string): boolean {
return /^[A-Za-z0-9+/=]+$/.test(cursor);
}
/**
* Fetch comments for a specific issue using GitHub GraphQL API
*/
async function fetchIssueComments(
projectPath: string,
owner: string,
repo: string,
issueNumber: number,
cursor?: string
): Promise<IssueCommentsResult> {
// Validate cursor format to prevent potential injection
if (cursor && !isValidCursor(cursor)) {
throw new Error('Invalid cursor format');
}
// Use GraphQL variables instead of string interpolation for safety
const query = `
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
comments(first: 50, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
}
}
}
}`;
const variables = {
owner,
repo,
issueNumber,
cursor: cursor || null,
};
const requestBody = JSON.stringify({ query, variables });
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
// Add timeout to prevent hanging indefinitely
const timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
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) => {
clearTimeout(timeoutId);
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();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const commentsData = response.data?.repository?.issue?.comments;
if (!commentsData) {
throw new Error('Issue not found or no comments data available');
}
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
id: node.id,
author: {
login: node.author?.login || 'ghost',
avatarUrl: node.author?.avatarUrl,
},
body: node.body,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
}));
return {
comments,
totalCount: commentsData.totalCount,
hasNextPage: commentsData.pageInfo.hasNextPage,
endCursor: commentsData.pageInfo.endCursor || undefined,
};
}
export function createListCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest;
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;
}
// First check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await fetchIssueComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
issueNumber,
cursor
);
res.json({
success: true,
...result,
});
} catch (error) {
logError(error, `Fetch comments for issue failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -2,7 +2,6 @@
* POST /list-issues endpoint - List GitHub issues for a project
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
@@ -14,19 +13,6 @@ export interface GitHubLabel {
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 {
@@ -38,8 +24,6 @@ export interface GitHubIssue {
labels: GitHubLabel[];
url: string;
body: string;
assignees: GitHubAssignee[];
linkedPRs?: LinkedPullRequest[];
}
export interface ListIssuesResult {
@@ -49,146 +33,6 @@ export interface ListIssuesResult {
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;
}
export function createListIssuesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -209,17 +53,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 +77,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,

View File

@@ -1,340 +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,
GitHubComment,
LinkedPRInfo,
} from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
ValidationComment,
ValidationLinkedPR,
} 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;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
linkedPRs?: LinkedPRInfo[];
}
/**
* 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,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): 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 (include comments and linked PRs if provided)
const prompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
issueLabels,
comments,
linkedPRs
);
// 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;
for await (const msg of stream) {
// Emit progress events for assistant text
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
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;
}
}
// 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');
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',
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
// Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost',
createdAt: c.createdAt,
body: c.body,
}));
// Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided
const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({
number: pr.number,
title: pr.title,
state: pr.state,
}));
logger.info(
`[ValidateIssue] Received validation request for issue #${issueNumber}` +
(rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') +
(rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '')
);
// 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,
validationComments,
validationLinkedPRs
)
.catch(() => {
// Error is already handled inside runValidation (event emitted)
})
.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),
});
}
}
};
}

View File

@@ -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 };

View File

@@ -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) });
}
};
}

View File

@@ -1,239 +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',
},
prAnalysis: {
type: 'object',
properties: {
hasOpenPR: {
type: 'boolean',
description: 'Whether there is an open PR linked to this issue',
},
prFixesIssue: {
type: 'boolean',
description: 'Whether the PR appears to fix the issue based on the diff',
},
prNumber: {
type: 'number',
description: 'The PR number that was analyzed',
},
prSummary: {
type: 'string',
description: 'Brief summary of what the PR changes',
},
recommendation: {
type: 'string',
enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'],
description:
'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR',
},
},
description: 'Analysis of linked pull requests if any exist',
},
},
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. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
## For Bug Reports, Check:
- Do the referenced files/components exist?
- Does the code match what the issue describes?
- Is the described behavior actually a bug or expected?
- Can you locate the code that would cause the reported issue?
## For Feature Requests, Check:
- Does the feature already exist?
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Analyzing Linked Pull Requests
When an issue has linked PRs (especially open ones), you MUST analyze them:
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
4. **Provide a recommendation**:
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
- \`no_pr\`: No relevant PR exists for this issue.
5. **Include prAnalysis in your response** with:
- hasOpenPR: true/false
- prFixesIssue: true/false (based on diff analysis)
- prNumber: the PR number you analyzed
- prSummary: brief description of what the PR changes
- recommendation: one of the above values
## Response Guidelines
- **Always include relatedFiles** when you find relevant code
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
- moderate: Changes to multiple files or moderate logic changes
- complex: Significant refactoring or new feature implementation
- very_complex: Major architectural changes or cross-cutting concerns
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/**
* Comment data structure for validation prompt
*/
export interface ValidationComment {
author: string;
createdAt: string;
body: string;
}
/**
* Linked PR data structure for validation prompt
*/
export interface ValidationLinkedPR {
number: number;
title: string;
state: string;
}
/**
* 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
* @param comments - Optional array of comments to include in analysis
* @param linkedPRs - Optional array of linked pull requests
* @returns Formatted prompt string for the validation request
*/
export function buildValidationPrompt(
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels?: string[],
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): string {
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
let linkedPRsSection = '';
if (linkedPRs && linkedPRs.length > 0) {
const prsText = linkedPRs
.map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`)
.join('\n');
linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`;
}
let commentsSection = '';
if (comments && comments.length > 0) {
// Limit to most recent 10 comments to control prompt size
const recentComments = comments.slice(-10);
const commentsText = recentComments
.map(
(c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}`
)
.join('\n\n---\n\n');
commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`;
}
const hasWorkInProgress =
linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN');
const workInProgressNote = hasWorkInProgress
? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.'
: '';
return `Please validate the following GitHub issue by analyzing the codebase:
## Issue #${issueNumber}: ${issueTitle}
${labelsSection}
${linkedPRsSection}
### Description
${issueBody || '(No description provided)'}
${commentsSection}
${workInProgressNote}
---
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.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`;
}

View File

@@ -1,25 +1,16 @@
/**
* Health check routes
*
* NOTE: Only the basic health check (/) is unauthenticated.
* The /detailed endpoint requires authentication.
*/
import { Router } from 'express';
import { createIndexHandler } from './routes/index.js';
import { createDetailedHandler } from './routes/detailed.js';
/**
* Create unauthenticated health routes (basic check only)
* Used by load balancers and container orchestration
*/
export function createHealthRoutes(): Router {
const router = Router();
// Basic health check - no sensitive info
router.get('/', createIndexHandler());
router.get('/detailed', createDetailedHandler());
return router;
}
// Re-export detailed handler for use in authenticated routes
export { createDetailedHandler } from './routes/detailed.js';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,8 @@ import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const runningAgents = await autoModeService.getRunningAgents();
const runningAgents = autoModeService.getRunningAgents();
const status = autoModeService.getStatus();
res.json({
success: true,

View File

@@ -94,37 +94,23 @@ async function getGhStatus(): Promise<GhStatus> {
// Version command failed
}
// Check authentication status by actually making an API call
// gh auth status can return non-zero even when GH_TOKEN is valid
let apiCallSucceeded = false;
// Check authentication status
try {
const { stdout } = await execAsync('gh api user --jq ".login"', { env: execEnv });
const user = stdout.trim();
if (user) {
status.authenticated = true;
status.user = user;
apiCallSucceeded = true;
const { stdout } = await execAsync('gh auth status', { env: execEnv });
// If this succeeds without error, we're authenticated
status.authenticated = true;
// Try to extract username from output
const userMatch =
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
}
// If stdout is empty, fall through to gh auth status fallback
} catch {
// API call failed - fall through to gh auth status fallback
}
// Fallback: try gh auth status if API call didn't succeed
if (!apiCallSucceeded) {
try {
const { stdout } = await execAsync('gh auth status', { env: execEnv });
status.authenticated = true;
// Try to extract username from output
const userMatch =
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
}
} catch {
// Auth status returns non-zero if not authenticated
} catch (error: unknown) {
// Auth status returns non-zero if not authenticated
const err = error as { stderr?: string };
if (err.stderr?.includes('not logged in')) {
status.authenticated = false;
}
}

View File

@@ -6,94 +6,9 @@ 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';
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 +42,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 +51,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,20 +63,14 @@ 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
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[Suggestions]'
);
events.emit('suggestions:event', {
type: 'suggestions_progress',
content: `Starting ${suggestionType} analysis...\n`,
});
const options = createSuggestionsOptions({
cwd: projectPath,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: suggestionsSchema,

View File

@@ -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());

View File

@@ -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', {

View File

@@ -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,

View File

@@ -12,20 +12,10 @@ 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,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
interface Message {
id: string;
@@ -40,14 +30,6 @@ interface Message {
isError?: boolean;
}
interface QueuedPrompt {
id: string;
message: string;
imagePaths?: string[];
model?: string;
addedAt: string;
}
interface Session {
messages: Message[];
isRunning: boolean;
@@ -55,7 +37,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 {
@@ -76,14 +57,11 @@ export class AgentService {
private stateDir: string;
private metadataFile: string;
private events: EventEmitter;
private settingsService: SettingsService | null = null;
private logger = createLogger('AgentService');
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> {
@@ -112,16 +90,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,
});
}
@@ -151,12 +125,10 @@ export class AgentService {
}) {
const session = this.sessions.get(sessionId);
if (!session) {
this.logger.error('ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`);
}
if (session.isRunning) {
this.logger.error('ERROR: Agent already running for session:', sessionId);
throw new Error('Agent is already processing a message');
}
@@ -178,7 +150,7 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
this.logger.error(`Failed to load image ${imagePath}:`, error);
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
@@ -202,11 +174,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',
@@ -219,37 +186,14 @@ 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 MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
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 = await this.getSystemPrompt();
const baseSystemPrompt = this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt;
@@ -261,11 +205,6 @@ export class AgentService {
sessionModel: session.model,
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -276,22 +215,21 @@ export class AgentService {
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
console.log(
`[AgentService] 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
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
};
// Build prompt content with images
@@ -316,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;
console.log(`[AgentService] 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 });
}
@@ -380,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,
@@ -394,7 +330,7 @@ export class AgentService {
return { success: false, aborted: true };
}
this.logger.error('Error:', error);
console.error('[AgentService] Error:', error);
session.isRunning = false;
session.abortController = null;
@@ -488,7 +424,7 @@ export class AgentService {
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
await this.updateSessionTimestamp(sessionId);
} catch (error) {
this.logger.error('Failed to save session:', error);
console.error('[AgentService] Failed to save session:', error);
}
}
@@ -621,173 +557,42 @@ 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) {
this.logger.error('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) {
this.logger.error('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 });
}
private async getSystemPrompt(): Promise<string> {
// Load from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');
return prompts.agent.systemPrompt;
private getSystemPrompt(): string {
return `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
}
private generateId(): string {

View File

@@ -10,7 +10,7 @@
*/
import { ProviderFactory } from '../providers/provider-factory.js';
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
import type { ExecuteOptions, Feature } from '@automaker/types';
import {
buildPromptWithImages,
isAbortError,
@@ -25,22 +25,8 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import {
createAutoModeOptions,
createCustomOptions,
validateWorkingDirectory,
} from '../lib/sdk-options.js';
import { createAutoModeOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js';
import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
const execAsync = promisify(exec);
@@ -68,6 +54,162 @@ interface PlanSpec {
tasks?: ParsedTask[];
}
const PLANNING_PROMPTS = {
lite: `## Planning Phase (Lite Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
Create a brief planning outline:
1. **Goal**: What are we accomplishing? (1 sentence)
2. **Approach**: How will we do it? (2-3 sentences)
3. **Files to Touch**: List files and what changes
4. **Tasks**: Numbered task list (3-7 items)
5. **Risks**: Any gotchas to watch for
After generating the outline, output:
"[PLAN_GENERATED] Planning outline complete."
Then proceed with implementation.`,
lite_with_approval: `## Planning Phase (Lite Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
Create a brief planning outline:
1. **Goal**: What are we accomplishing? (1 sentence)
2. **Approach**: How will we do it? (2-3 sentences)
3. **Files to Touch**: List files and what changes
4. **Tasks**: Numbered task list (3-7 items)
5. **Risks**: Any gotchas to watch for
After generating the outline, output:
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.`,
spec: `## Specification Phase (Spec Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
### Specification Format
1. **Problem**: What problem are we solving? (user perspective)
2. **Solution**: Brief approach (1-2 sentences)
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
- GIVEN [context], WHEN [action], THEN [outcome]
4. **Files to Modify**:
| File | Purpose | Action |
|------|---------|--------|
| path/to/file | description | create/modify/delete |
5. **Implementation Tasks**:
Use this EXACT format for each task (the system will parse these):
\`\`\`tasks
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
- [ ] T003: [Description] | File: [path/to/file]
\`\`\`
Task ID rules:
- Sequential: T001, T002, T003, etc.
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
- File: Primary file affected (helps with context)
- Order by dependencies (foundational tasks first)
6. **Verification**: How to confirm feature works
After generating the spec, output on its own line:
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.
When approved, execute tasks SEQUENTIALLY in order. For each task:
1. BEFORE starting, output: "[TASK_START] T###: Description"
2. Implement the task
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
This allows real-time progress tracking during implementation.`,
full: `## Full Specification Phase (Full SDD Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
### Specification Format
1. **Problem Statement**: 2-3 sentences from user perspective
2. **User Story**: As a [user], I want [goal], so that [benefit]
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
4. **Technical Context**:
| Aspect | Value |
|--------|-------|
| Affected Files | list of files |
| Dependencies | external libs if any |
| Constraints | technical limitations |
| Patterns to Follow | existing patterns in codebase |
5. **Non-Goals**: What this feature explicitly does NOT include
6. **Implementation Tasks**:
Use this EXACT format for each task (the system will parse these):
\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
## Phase 2: Core Implementation
- [ ] T003: [Description] | File: [path/to/file]
- [ ] T004: [Description] | File: [path/to/file]
## Phase 3: Integration & Testing
- [ ] T005: [Description] | File: [path/to/file]
- [ ] T006: [Description] | File: [path/to/file]
\`\`\`
Task ID rules:
- Sequential across all phases: T001, T002, T003, etc.
- Description: Clear action verb + target
- File: Primary file affected
- Order by dependencies within each phase
- Phase structure helps organize complex work
7. **Success Metrics**: How we know it's done (measurable criteria)
8. **Risks & Mitigations**:
| Risk | Mitigation |
|------|------------|
| description | approach |
After generating the spec, output on its own line:
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.
When approved, execute tasks SEQUENTIALLY by phase. For each task:
1. BEFORE starting, output: "[TASK_START] T###: Description"
2. Implement the task
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
After completing all tasks in a phase, output:
"[PHASE_COMPLETE] Phase N complete"
This allows real-time progress tracking during implementation.`,
};
/**
* Parse tasks from generated spec content
* Looks for the ```tasks code block and extracts task lines
@@ -199,11 +341,9 @@ export class AutoModeService {
private autoLoopAbortController: AbortController | null = null;
private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
constructor(events: EventEmitter, settingsService?: SettingsService) {
constructor(events: EventEmitter) {
this.events = events;
this.settingsService = settingsService ?? null;
}
/**
@@ -411,25 +551,14 @@ export class AutoModeService {
// Update feature status to in_progress
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
let prompt: string;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
if (options?.continuationPrompt) {
// Continuation prompt is used when recovering from a plan approval
// The plan was already approved, so skip the planning phase
@@ -438,7 +567,7 @@ export class AutoModeService {
} else {
// Normal flow: build prompt with planning phase
const featurePrompt = this.buildFeaturePrompt(feature);
const planningPrefix = await this.getPlanningPromptPrefix(feature);
const planningPrefix = this.getPlanningPromptPrefix(feature);
prompt = planningPrefix + featurePrompt;
// Emit planning mode info
@@ -475,27 +604,9 @@ export class AutoModeService {
planningMode: feature.planningMode,
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
// Check for pipeline steps and execute them
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially
await this.executePipelineSteps(
projectPath,
featureId,
feature,
sortedSteps,
workDir,
abortController,
autoLoadClaudeMd
);
}
// Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
@@ -539,143 +650,6 @@ export class AutoModeService {
}
}
/**
* Execute pipeline steps sequentially after initial feature implementation
*/
private async executePipelineSteps(
projectPath: string,
featureId: string,
feature: Feature,
steps: PipelineStep[],
workDir: string,
abortController: AbortController,
autoLoadClaudeMd: boolean
): Promise<void> {
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Load context files once
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Load previous agent output for context continuity
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const pipelineStatus = `pipeline_${step.id}`;
// Update feature status to current pipeline step
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
this.emitAutoModeEvent('pipeline_step_started', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
// Build prompt for this pipeline step
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
// Run the agent for this pipeline step
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
undefined, // no images for pipeline steps
model,
{
projectPath,
planningMode: 'skip', // Pipeline steps don't need planning
requirePlanApproval: false,
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
// Load updated context for next step
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No context update
}
this.emitAutoModeEvent('pipeline_step_complete', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
console.log(
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
);
}
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
}
/**
* Build the prompt for a pipeline step
*/
private buildPipelineStepPrompt(
step: PipelineStep,
feature: Feature,
previousContext: string
): string {
let prompt = `## Pipeline Step: ${step.name}
This is an automated pipeline step following the initial feature implementation.
### Feature Context
${this.buildFeaturePrompt(feature)}
`;
if (previousContext) {
prompt += `### Previous Work
The following is the output from the previous work on this feature:
${previousContext}
`;
}
prompt += `### Pipeline Step Instructions
${step.instructions}
### Task
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
return prompt;
}
/**
* Stop a specific feature
*/
@@ -772,23 +746,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
// No previous context
}
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
const contextResult = await loadContextFiles({
const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build complete prompt with feature info, previous context, and follow-up instructions
let fullPrompt = `## Follow-up on Feature Implementation
@@ -916,7 +879,6 @@ Address the follow-up instructions above. Review the previous work and make the
planningMode: 'skip', // Follow-ups don't require approval
previousContent: previousContext || undefined,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
@@ -1103,6 +1065,11 @@ Address the follow-up instructions above. Review the previous work and make the
* Analyze project to gather context
*/
async analyzeProject(projectPath: string): Promise<void> {
// Validate project path before proceeding
// This is called here because analyzeProject builds ExecuteOptions directly
// without using a factory function from sdk-options.ts
validateWorkingDirectory(projectPath);
const abortController = new AbortController();
const analysisFeatureId = `analysis-${Date.now()}`;
@@ -1130,32 +1097,13 @@ Format your response as a structured markdown document.`;
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(analysisModel);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
// Use createCustomOptions for centralized SDK configuration with CLAUDE.md support
const sdkOptions = createCustomOptions({
cwd: projectPath,
model: analysisModel,
maxTurns: 5,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
autoLoadClaudeMd,
});
const options: ExecuteOptions = {
prompt,
model: sdkOptions.model ?? analysisModel,
cwd: sdkOptions.cwd ?? projectPath,
maxTurns: sdkOptions.maxTurns,
allowedTools: sdkOptions.allowedTools as string[],
model: analysisModel,
maxTurns: 5,
cwd: projectPath,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
};
const stream = provider.executeQuery(options);
@@ -1219,43 +1167,18 @@ Format your response as a structured markdown document.`;
/**
* Get detailed info about all running agents
*/
async getRunningAgents(): Promise<
Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
title?: string;
description?: string;
}>
> {
const agents = await Promise.all(
Array.from(this.runningFeatures.values()).map(async (rf) => {
// Try to fetch feature data to get title and description
let title: string | undefined;
let description: string | undefined;
try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
}
} catch (error) {
// Silently ignore errors - title/description are optional
}
return {
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
title,
description,
};
})
);
return agents;
getRunningAgents(): Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}> {
return Array.from(this.runningFeatures.values()).map((rf) => ({
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
}));
}
/**
@@ -1629,29 +1552,20 @@ Format your response as a structured markdown document.`;
/**
* Get the planning prompt prefix based on feature's planning mode
*/
private async getPlanningPromptPrefix(feature: Feature): Promise<string> {
private getPlanningPromptPrefix(feature: Feature): string {
const mode = feature.planningMode || 'skip';
if (mode === 'skip') {
return ''; // No planning phase
}
// Load prompts from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const planningPrompts: Record<string, string> = {
lite: prompts.autoMode.planningLite,
lite_with_approval: prompts.autoMode.planningLiteWithApproval,
spec: prompts.autoMode.planningSpec,
full: prompts.autoMode.planningFull,
};
// For lite mode, use the approval variant if requirePlanApproval is true
let promptKey: string = mode;
if (mode === 'lite' && feature.requirePlanApproval === true) {
promptKey = 'lite_with_approval';
}
const planningPrompt = planningPrompts[promptKey];
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
if (!planningPrompt) {
return '';
}
@@ -1794,7 +1708,6 @@ This helps parse your summary correctly in the output logs.`;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
@@ -1867,32 +1780,11 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
return;
}
// Load autoLoadClaudeMd setting (project setting takes precedence over global)
// Use provided value if available, otherwise load from settings
const autoLoadClaudeMd =
options?.autoLoadClaudeMd !== undefined
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
// Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({
cwd: workDir,
model: model,
abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -1931,12 +1823,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
cwd: workDir,
allowedTools: allowedTools,
abortController,
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
systemPrompt: options?.systemPrompt,
};
// Execute via provider
@@ -2164,9 +2051,6 @@ After generating the revised spec, output:
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
let revisionText = '';
@@ -2304,9 +2188,6 @@ After generating the revised spec, output:
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
let taskOutput = '';
@@ -2396,9 +2277,6 @@ Implement all the changes described in the plan above.`;
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
for await (const msg of continuationStream) {

View File

@@ -12,13 +12,12 @@ import { ClaudeUsage } from '../routes/claude/types.js';
*
* Platform-specific implementations:
* - macOS: Uses 'expect' command for PTY
* - Windows/Linux: Uses node-pty for PTY
* - Windows: Uses node-pty for PTY
*/
export class ClaudeUsageService {
private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout
private isWindows = os.platform() === 'win32';
private isLinux = os.platform() === 'linux';
/**
* Check if Claude CLI is available on the system
@@ -49,8 +48,8 @@ export class ClaudeUsageService {
* Uses platform-specific PTY implementation
*/
private executeClaudeUsageCommand(): Promise<string> {
if (this.isWindows || this.isLinux) {
return this.executeClaudeUsageCommandPty();
if (this.isWindows) {
return this.executeClaudeUsageCommandWindows();
}
return this.executeClaudeUsageCommandMac();
}
@@ -148,23 +147,17 @@ export class ClaudeUsageService {
}
/**
* Windows/Linux implementation using node-pty
* Windows implementation using node-pty
*/
private executeClaudeUsageCommandPty(): Promise<string> {
private executeClaudeUsageCommandWindows(): Promise<string> {
return new Promise((resolve, reject) => {
let output = '';
let settled = false;
let hasSeenUsageData = false;
const workingDirectory = this.isWindows
? process.env.USERPROFILE || os.homedir() || 'C:\\'
: process.env.HOME || os.homedir() || '/tmp';
const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\';
// Use platform-appropriate shell and command
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
const ptyProcess = pty.spawn(shell, args, {
const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], {
name: 'xterm-256color',
cols: 120,
rows: 30,

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockExpressContext } from '../../utils/mocks.js';
import fs from 'fs';
import path from 'path';
/**
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
@@ -10,13 +8,26 @@ import path from 'path';
describe('auth.ts', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.AUTOMAKER_API_KEY;
delete process.env.AUTOMAKER_HIDE_API_KEY;
delete process.env.NODE_ENV;
});
describe('authMiddleware', () => {
it('should reject request without any authentication', async () => {
describe('authMiddleware - no API key', () => {
it('should call next() when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('authMiddleware - with API key', () => {
it('should reject request without API key header', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
@@ -25,7 +36,7 @@ describe('auth.ts', () => {
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Authentication required.',
error: 'Authentication required. Provide X-API-Key header.',
});
expect(next).not.toHaveBeenCalled();
});
@@ -59,340 +70,46 @@ describe('auth.ts', () => {
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session token in header', async () => {
const { authMiddleware, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = token;
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject invalid session token in header', async () => {
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.headers['x-session-token'] = 'invalid-token';
authMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid or expired session token.',
});
expect(next).not.toHaveBeenCalled();
});
it('should authenticate with API key in query parameter', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { authMiddleware } = await import('@/lib/auth.js');
const { req, res, next } = createMockExpressContext();
req.query.apiKey = 'test-secret-key';
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should authenticate with session cookie', async () => {
const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
const { req, res, next } = createMockExpressContext();
req.cookies = { [cookieName]: token };
authMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('createSession', () => {
it('should create a new session and return token', async () => {
const { createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens for each session', async () => {
const { createSession } = await import('@/lib/auth.js');
const token1 = await createSession();
const token2 = await createSession();
expect(token1).not.toBe(token2);
});
});
describe('validateSession', () => {
it('should validate a valid session token', async () => {
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
});
it('should reject invalid session token', async () => {
const { validateSession } = await import('@/lib/auth.js');
expect(validateSession('invalid-token')).toBe(false);
});
it('should reject expired session token', async () => {
vi.useFakeTimers();
const { createSession, validateSession } = await import('@/lib/auth.js');
const token = await createSession();
// Advance time past session expiration (30 days)
vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000);
expect(validateSession(token)).toBe(false);
vi.useRealTimers();
});
});
describe('invalidateSession', () => {
it('should invalidate a session token', async () => {
const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(validateSession(token)).toBe(true);
await invalidateSession(token);
expect(validateSession(token)).toBe(false);
});
});
describe('createWsConnectionToken', () => {
it('should create a WebSocket connection token', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should create unique tokens', async () => {
const { createWsConnectionToken } = await import('@/lib/auth.js');
const token1 = createWsConnectionToken();
const token2 = createWsConnectionToken();
expect(token1).not.toBe(token2);
});
});
describe('validateWsConnectionToken', () => {
it('should validate a valid WebSocket token', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
});
it('should reject invalid WebSocket token', async () => {
const { validateWsConnectionToken } = await import('@/lib/auth.js');
expect(validateWsConnectionToken('invalid-token')).toBe(false);
});
it('should reject expired WebSocket token', async () => {
vi.useFakeTimers();
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
// Advance time past token expiration (5 minutes)
vi.advanceTimersByTime(6 * 60 * 1000);
expect(validateWsConnectionToken(token)).toBe(false);
vi.useRealTimers();
});
it('should invalidate token after first use (single-use)', async () => {
const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js');
const token = createWsConnectionToken();
expect(validateWsConnectionToken(token)).toBe(true);
// Token should be deleted after first use
expect(validateWsConnectionToken(token)).toBe(false);
});
});
describe('validateApiKey', () => {
it('should validate correct API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('test-secret-key')).toBe(true);
});
it('should reject incorrect API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('wrong-key')).toBe(false);
});
it('should reject empty string', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey('')).toBe(false);
});
it('should reject null/undefined', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
expect(validateApiKey(null as any)).toBe(false);
expect(validateApiKey(undefined as any)).toBe(false);
});
it('should use timing-safe comparison for different lengths', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { validateApiKey } = await import('@/lib/auth.js');
// Key with different length should be rejected without timing leak
expect(validateApiKey('short')).toBe(false);
expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false);
});
});
describe('getSessionCookieOptions', () => {
it('should return cookie options with httpOnly true', async () => {
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.httpOnly).toBe(true);
expect(options.sameSite).toBe('strict');
expect(options.path).toBe('/');
expect(options.maxAge).toBeGreaterThan(0);
});
it('should set secure to true in production', async () => {
process.env.NODE_ENV = 'production';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(true);
});
it('should set secure to false in non-production', async () => {
process.env.NODE_ENV = 'development';
const { getSessionCookieOptions } = await import('@/lib/auth.js');
const options = getSessionCookieOptions();
expect(options.secure).toBe(false);
});
});
describe('getSessionCookieName', () => {
it('should return the session cookie name', async () => {
const { getSessionCookieName } = await import('@/lib/auth.js');
const name = getSessionCookieName();
expect(name).toBe('automaker_session');
});
});
describe('isRequestAuthenticated', () => {
it('should return true for authenticated request with API key', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
req.headers['x-api-key'] = 'test-secret-key';
expect(isRequestAuthenticated(req)).toBe(true);
});
it('should return false for unauthenticated request', async () => {
const { isRequestAuthenticated } = await import('@/lib/auth.js');
const { req } = createMockExpressContext();
expect(isRequestAuthenticated(req)).toBe(false);
});
it('should return true for authenticated request with session token', async () => {
const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js');
const token = await createSession();
const { req } = createMockExpressContext();
req.headers['x-session-token'] = token;
expect(isRequestAuthenticated(req)).toBe(true);
});
});
describe('checkRawAuthentication', () => {
it('should return true for valid API key in headers', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true);
});
it('should return true for valid session token in headers', async () => {
const { checkRawAuthentication, createSession } = await import('@/lib/auth.js');
const token = await createSession();
expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true);
});
it('should return true for valid API key in query', async () => {
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true);
});
it('should return true for valid session cookie', async () => {
const { checkRawAuthentication, createSession, getSessionCookieName } =
await import('@/lib/auth.js');
const token = await createSession();
const cookieName = getSessionCookieName();
expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true);
});
it('should return false for invalid credentials', async () => {
const { checkRawAuthentication } = await import('@/lib/auth.js');
expect(checkRawAuthentication({}, {}, {})).toBe(false);
});
});
describe('isAuthEnabled', () => {
it('should always return true (auth is always required)', async () => {
it('should return false when no API key is set', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(false);
});
it('should return true when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
const { isAuthEnabled } = await import('@/lib/auth.js');
expect(isAuthEnabled()).toBe(true);
});
});
describe('getAuthStatus', () => {
it('should return enabled status with api_key_or_session method', async () => {
it('should return disabled status when no API key', async () => {
delete process.env.AUTOMAKER_API_KEY;
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: false,
method: 'none',
});
});
it('should return enabled status when API key is set', async () => {
process.env.AUTOMAKER_API_KEY = 'test-key';
const { getAuthStatus } = await import('@/lib/auth.js');
const status = getAuthStatus();
expect(status).toEqual({
enabled: true,
method: 'api_key_or_session',
method: 'api_key',
});
});
});

View File

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

View File

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

View File

@@ -1,307 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
writeValidation,
readValidation,
getAllValidations,
deleteValidation,
isValidationStale,
getValidationWithFreshness,
markValidationViewed,
getUnviewedValidationsCount,
type StoredValidation,
} from '@/lib/validation-storage.js';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe('validation-storage.ts', () => {
let testProjectPath: string;
beforeEach(async () => {
testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`);
await fs.mkdir(testProjectPath, { recursive: true });
});
afterEach(async () => {
try {
await fs.rm(testProjectPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
const createMockValidation = (overrides: Partial<StoredValidation> = {}): StoredValidation => ({
issueNumber: 123,
issueTitle: 'Test Issue',
validatedAt: new Date().toISOString(),
model: 'haiku',
result: {
verdict: 'valid',
confidence: 'high',
reasoning: 'Test reasoning',
},
...overrides,
});
describe('writeValidation', () => {
it('should write validation to storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
// Verify file was created
const validationPath = path.join(
testProjectPath,
'.automaker',
'validations',
'123',
'validation.json'
);
const content = await fs.readFile(validationPath, 'utf-8');
expect(JSON.parse(content)).toEqual(validation);
});
it('should create nested directories if they do not exist', async () => {
const validation = createMockValidation({ issueNumber: 456 });
await writeValidation(testProjectPath, 456, validation);
const validationPath = path.join(
testProjectPath,
'.automaker',
'validations',
'456',
'validation.json'
);
const content = await fs.readFile(validationPath, 'utf-8');
expect(JSON.parse(content)).toEqual(validation);
});
});
describe('readValidation', () => {
it('should read validation from storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await readValidation(testProjectPath, 123);
expect(result).toEqual(validation);
});
it('should return null when validation does not exist', async () => {
const result = await readValidation(testProjectPath, 999);
expect(result).toBeNull();
});
});
describe('getAllValidations', () => {
it('should return all validations for a project', async () => {
const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' });
const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' });
const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' });
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
await writeValidation(testProjectPath, 3, validation3);
const result = await getAllValidations(testProjectPath);
expect(result).toHaveLength(3);
expect(result[0]).toEqual(validation1);
expect(result[1]).toEqual(validation2);
expect(result[2]).toEqual(validation3);
});
it('should return empty array when no validations exist', async () => {
const result = await getAllValidations(testProjectPath);
expect(result).toEqual([]);
});
it('should skip non-numeric directories', async () => {
const validation = createMockValidation({ issueNumber: 1 });
await writeValidation(testProjectPath, 1, validation);
// Create a non-numeric directory
const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid');
await fs.mkdir(invalidDir, { recursive: true });
const result = await getAllValidations(testProjectPath);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(validation);
});
});
describe('deleteValidation', () => {
it('should delete validation from storage', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await deleteValidation(testProjectPath, 123);
expect(result).toBe(true);
const readResult = await readValidation(testProjectPath, 123);
expect(readResult).toBeNull();
});
it('should return true even when validation does not exist', async () => {
const result = await deleteValidation(testProjectPath, 999);
expect(result).toBe(true);
});
});
describe('isValidationStale', () => {
it('should return false for recent validation', () => {
const validation = createMockValidation({
validatedAt: new Date().toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(false);
});
it('should return true for validation older than 24 hours', () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago
const validation = createMockValidation({
validatedAt: oldDate.toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(true);
});
it('should return false for validation exactly at 24 hours', () => {
const exactDate = new Date();
exactDate.setHours(exactDate.getHours() - 24);
const validation = createMockValidation({
validatedAt: exactDate.toISOString(),
});
const result = isValidationStale(validation);
expect(result).toBe(false);
});
});
describe('getValidationWithFreshness', () => {
it('should return validation with isStale false for recent validation', async () => {
const validation = createMockValidation({
validatedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 123, validation);
const result = await getValidationWithFreshness(testProjectPath, 123);
expect(result).not.toBeNull();
expect(result!.validation).toEqual(validation);
expect(result!.isStale).toBe(false);
});
it('should return validation with isStale true for old validation', async () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25);
const validation = createMockValidation({
validatedAt: oldDate.toISOString(),
});
await writeValidation(testProjectPath, 123, validation);
const result = await getValidationWithFreshness(testProjectPath, 123);
expect(result).not.toBeNull();
expect(result!.isStale).toBe(true);
});
it('should return null when validation does not exist', async () => {
const result = await getValidationWithFreshness(testProjectPath, 999);
expect(result).toBeNull();
});
});
describe('markValidationViewed', () => {
it('should mark validation as viewed', async () => {
const validation = createMockValidation();
await writeValidation(testProjectPath, 123, validation);
const result = await markValidationViewed(testProjectPath, 123);
expect(result).toBe(true);
const updated = await readValidation(testProjectPath, 123);
expect(updated).not.toBeNull();
expect(updated!.viewedAt).toBeDefined();
});
it('should return false when validation does not exist', async () => {
const result = await markValidationViewed(testProjectPath, 999);
expect(result).toBe(false);
});
});
describe('getUnviewedValidationsCount', () => {
it('should return count of unviewed non-stale validations', async () => {
const validation1 = createMockValidation({ issueNumber: 1 });
const validation2 = createMockValidation({ issueNumber: 2 });
const validation3 = createMockValidation({
issueNumber: 3,
viewedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
await writeValidation(testProjectPath, 3, validation3);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(2);
});
it('should not count stale validations', async () => {
const oldDate = new Date();
oldDate.setHours(oldDate.getHours() - 25);
const validation1 = createMockValidation({ issueNumber: 1 });
const validation2 = createMockValidation({
issueNumber: 2,
validatedAt: oldDate.toISOString(),
});
await writeValidation(testProjectPath, 1, validation1);
await writeValidation(testProjectPath, 2, validation2);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(1);
});
it('should return 0 when no validations exist', async () => {
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(0);
});
it('should return 0 when all validations are viewed', async () => {
const validation = createMockValidation({
issueNumber: 1,
viewedAt: new Date().toISOString(),
});
await writeValidation(testProjectPath, 1, validation);
const result = await getUnviewedValidationsCount(testProjectPath);
expect(result).toBe(0);
});
});
});

View File

@@ -73,7 +73,7 @@ describe('claude-provider.ts', () => {
maxTurns: 10,
cwd: '/test/dir',
allowedTools: ['Read', 'Write'],
permissionMode: 'default',
permissionMode: 'acceptEdits',
}),
});
});
@@ -100,7 +100,7 @@ describe('claude-provider.ts', () => {
});
});
it('should pass sandbox configuration when provided', async () => {
it('should enable sandbox by default', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -110,10 +110,6 @@ describe('claude-provider.ts', () => {
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
});
await collectAsyncGenerator(generator);
@@ -246,16 +242,10 @@ describe('claude-provider.ts', () => {
});
await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed');
// Should log error with classification info (after refactoring)
const errorCall = consoleErrorSpy.mock.calls[0];
expect(errorCall[0]).toBe('[ClaudeProvider] executeQuery() error during execution:');
expect(errorCall[1]).toMatchObject({
type: expect.any(String),
message: 'SDK execution failed',
isRateLimit: false,
stack: expect.stringContaining('Error: SDK execution failed'),
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[ClaudeProvider] executeQuery() error during execution:',
testError
);
consoleErrorSpy.mockRestore();
});

View File

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

View File

@@ -1,195 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createIndexHandler } from '@/routes/running-agents/routes/index.js';
import type { AutoModeService } from '@/services/auto-mode-service.js';
import { createMockExpressContext } from '../../utils/mocks.js';
describe('running-agents routes', () => {
let mockAutoModeService: Partial<AutoModeService>;
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
mockAutoModeService = {
getRunningAgents: vi.fn(),
};
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('GET / (index handler)', () => {
it('should return empty array when no agents are running', async () => {
// Arrange
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue([]);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(mockAutoModeService.getRunningAgents).toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents: [],
totalCount: 0,
});
});
it('should return running agents with all properties', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-123',
projectPath: '/home/user/project',
projectName: 'project',
isAutoMode: true,
title: 'Implement login feature',
description: 'Add user authentication with OAuth',
},
{
featureId: 'feature-456',
projectPath: '/home/user/other-project',
projectName: 'other-project',
isAutoMode: false,
title: 'Fix navigation bug',
description: undefined,
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 2,
});
});
it('should return agents without title/description (backward compatibility)', async () => {
// Arrange
const runningAgents = [
{
featureId: 'legacy-feature',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
title: undefined,
description: undefined,
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 1,
});
});
it('should handle errors gracefully and return 500', async () => {
// Arrange
const error = new Error('Database connection failed');
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue(error);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Database connection failed',
});
});
it('should handle non-Error exceptions', async () => {
// Arrange
vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue('String error');
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: expect.any(String),
});
});
it('should correctly count multiple running agents', async () => {
// Arrange
const runningAgents = Array.from({ length: 10 }, (_, i) => ({
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
projectName: `project-${i}`,
isAutoMode: i % 2 === 0,
title: `Feature ${i}`,
description: `Description ${i}`,
}));
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
expect(res.json).toHaveBeenCalledWith({
success: true,
runningAgents,
totalCount: 10,
});
});
it('should include agents from different projects', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-a',
projectPath: '/workspace/project-alpha',
projectName: 'project-alpha',
isAutoMode: true,
title: 'Feature A',
description: 'In project alpha',
},
{
featureId: 'feature-b',
projectPath: '/workspace/project-beta',
projectName: 'project-beta',
isAutoMode: false,
title: 'Feature B',
description: 'In project beta',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
});
});
});

View File

@@ -7,26 +7,9 @@ import * as promptBuilder from '@automaker/utils';
import * as contextLoader from '@automaker/utils';
import { collectAsyncGenerator } from '../../utils/helpers.js';
// Create a shared mock logger instance for assertions using vi.hoisted
const mockLogger = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}));
vi.mock('fs/promises');
vi.mock('@/providers/provider-factory.js');
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
loadContextFiles: vi.fn(),
buildPromptWithImages: vi.fn(),
readImageAsBase64: vi.fn(),
createLogger: vi.fn(() => mockLogger),
};
});
vi.mock('@automaker/utils');
describe('agent-service.ts', () => {
let service: AgentService;
@@ -123,9 +106,9 @@ describe('agent-service.ts', () => {
});
expect(result.success).toBe(true);
// First call reads session file, metadata file, and queue state file (3 calls)
// First call reads session file and metadata file (2 calls)
// Second call should reuse in-memory session (no additional calls)
expect(fs.readFile).toHaveBeenCalledTimes(3);
expect(fs.readFile).toHaveBeenCalledTimes(2);
});
});
@@ -241,13 +224,16 @@ describe('agent-service.ts', () => {
hasImages: false,
});
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await service.sendMessage({
sessionId: 'session-1',
message: 'Check this',
imagePaths: ['/path/test.png'],
});
expect(mockLogger.error).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should use custom model if provided', async () => {
@@ -361,386 +347,4 @@ describe('agent-service.ts', () => {
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('createSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should create a new session with metadata', async () => {
const session = await service.createSession('Test Session', '/test/project', '/test/dir');
expect(session.id).toBeDefined();
expect(session.name).toBe('Test Session');
expect(session.projectPath).toBe('/test/project');
expect(session.workingDirectory).toBeDefined();
expect(session.createdAt).toBeDefined();
expect(session.updatedAt).toBeDefined();
});
it('should use process.cwd() if no working directory provided', async () => {
const session = await service.createSession('Test Session');
expect(session.workingDirectory).toBeDefined();
});
it('should validate working directory', async () => {
// Set ALLOWED_ROOT_DIRECTORY to restrict paths
const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY;
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects';
// Re-import platform to initialize with new env var
vi.resetModules();
const { initAllowedPaths } = await import('@automaker/platform');
initAllowedPaths();
const { AgentService } = await import('@/services/agent-service.js');
const testService = new AgentService('/test/data', mockEvents as any);
vi.mocked(fs.readFile).mockResolvedValue('{}');
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await expect(
testService.createSession('Test Session', undefined, '/invalid/path')
).rejects.toThrow();
// Restore original value
if (originalAllowedRoot) {
process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot;
} else {
delete process.env.ALLOWED_ROOT_DIRECTORY;
}
vi.resetModules();
const { initAllowedPaths: reinit } = await import('@automaker/platform');
reinit();
});
});
describe('setSessionModel', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should set model for existing session', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}');
const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514');
expect(result).toBe(false);
});
});
describe('updateSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should update session metadata', async () => {
const result = await service.updateSession('session-1', { name: 'Updated Name' });
expect(result).not.toBeNull();
expect(result?.name).toBe('Updated Name');
expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z');
});
it('should return null for non-existent session', async () => {
const result = await service.updateSession('nonexistent', { name: 'Updated Name' });
expect(result).toBeNull();
});
});
describe('archiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should archive a session', async () => {
const result = await service.archiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.archiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('unarchiveSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
archived: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should unarchive a session', async () => {
const result = await service.unarchiveSession('session-1');
expect(result).toBe(true);
});
it('should return false for non-existent session', async () => {
const result = await service.unarchiveSession('nonexistent');
expect(result).toBe(false);
});
});
describe('deleteSession', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
})
);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.unlink).mockResolvedValue(undefined);
});
it('should delete a session', async () => {
const result = await service.deleteSession('session-1');
expect(result).toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
it('should return false for non-existent session', async () => {
const result = await service.deleteSession('nonexistent');
expect(result).toBe(false);
});
});
describe('listSessions', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({
'session-1': {
id: 'session-1',
name: 'Test Session 1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
archived: false,
},
'session-2': {
id: 'session-2',
name: 'Test Session 2',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
archived: true,
},
})
);
});
it('should list non-archived sessions by default', async () => {
const sessions = await service.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].id).toBe('session-1');
});
it('should include archived sessions when requested', async () => {
const sessions = await service.listSessions(true);
expect(sessions.length).toBe(2);
});
it('should sort sessions by updatedAt descending', async () => {
const sessions = await service.listSessions(true);
expect(sessions[0].id).toBe('session-2');
expect(sessions[1].id).toBe('session-1');
});
});
describe('addToQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should add prompt to queue', async () => {
const result = await service.addToQueue('session-1', {
message: 'Test prompt',
imagePaths: ['/test/image.png'],
model: 'claude-sonnet-4-20250514',
});
expect(result.success).toBe(true);
expect(result.queuedPrompt).toBeDefined();
expect(result.queuedPrompt?.message).toBe('Test prompt');
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.addToQueue('nonexistent', {
message: 'Test prompt',
});
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('getQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
});
it('should return queue for session', async () => {
await service.addToQueue('session-1', { message: 'Test prompt' });
const result = service.getQueue('session-1');
expect(result.success).toBe(true);
expect(result.queue).toBeDefined();
expect(result.queue?.length).toBe(1);
});
it('should return error for non-existent session', () => {
const result = service.getQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
describe('removeFromQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
const addResult = await service.addToQueue('session-1', { message: 'Test prompt' });
vi.clearAllMocks();
});
it('should remove prompt from queue', async () => {
const queueResult = service.getQueue('session-1');
const promptId = queueResult.queue![0].id;
const result = await service.removeFromQueue('session-1', promptId);
expect(result.success).toBe(true);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.removeFromQueue('nonexistent', 'prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
it('should return error for non-existent prompt', async () => {
const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id');
expect(result.success).toBe(false);
expect(result.error).toBe('Prompt not found in queue');
});
});
describe('clearQueue', () => {
beforeEach(async () => {
const error: any = new Error('ENOENT');
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await service.startConversation({
sessionId: 'session-1',
});
await service.addToQueue('session-1', { message: 'Test prompt 1' });
await service.addToQueue('session-1', { message: 'Test prompt 2' });
vi.clearAllMocks();
});
it('should clear all prompts from queue', async () => {
const result = await service.clearQueue('session-1');
expect(result.success).toBe(true);
const queueResult = service.getQueue('session-1');
expect(queueResult.queue?.length).toBe(0);
expect(mockEvents.emit).toHaveBeenCalled();
});
it('should return error for non-existent session', async () => {
const result = await service.clearQueue('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toBe('Session not found');
});
});
});

View File

@@ -24,87 +24,84 @@ describe('auto-mode-service.ts - Planning Mode', () => {
return svc.getPlanningPromptPrefix(feature);
};
it('should return empty string for skip mode', async () => {
it('should return empty string for skip mode', () => {
const feature = { id: 'test', planningMode: 'skip' as const };
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return empty string when planningMode is undefined', async () => {
it('should return empty string when planningMode is undefined', () => {
const feature = { id: 'test' };
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return lite prompt for lite mode without approval', async () => {
it('should return lite prompt for lite mode without approval', () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: false,
};
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[PLAN_GENERATED]');
expect(result).toContain('Feature Request');
});
it('should return lite_with_approval prompt for lite mode with approval', async () => {
it('should return lite_with_approval prompt for lite mode with approval', () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: true,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Planning Phase (Lite Mode)');
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[SPEC_GENERATED]');
expect(result).toContain(
'DO NOT proceed with implementation until you receive explicit approval'
);
expect(result).toContain('DO NOT proceed with implementation');
});
it('should return spec prompt for spec mode', async () => {
it('should return spec prompt for spec mode', () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Specification Phase (Spec Mode)');
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Specification Phase (Spec Mode)');
expect(result).toContain('```tasks');
expect(result).toContain('T001');
expect(result).toContain('[TASK_START]');
expect(result).toContain('[TASK_COMPLETE]');
});
it('should return full prompt for full mode', async () => {
it('should return full prompt for full mode', () => {
const feature = {
id: 'test',
planningMode: 'full' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Full Specification Phase (Full SDD Mode)');
expect(result).toContain('Phase 1: Foundation');
expect(result).toContain('Phase 2: Core Implementation');
expect(result).toContain('Phase 3: Integration & Testing');
});
it('should include the separator and Feature Request header', async () => {
it('should include the separator and Feature Request header', () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('---');
expect(result).toContain('## Feature Request');
});
it('should instruct agent to NOT output exploration text', async () => {
it('should instruct agent to NOT output exploration text', () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = await getPlanningPromptPrefix(service, feature);
// All modes should have the IMPORTANT instruction about not outputting exploration text
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
expect(result).toContain('Silently analyze the codebase first');
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Do NOT output exploration text');
expect(result).toContain('Start DIRECTLY');
}
});
});
@@ -282,18 +279,18 @@ describe('auto-mode-service.ts - Planning Mode', () => {
return svc.getPlanningPromptPrefix(feature);
};
it('should have all required planning modes', async () => {
it('should have all required planning modes', () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100);
}
});
it('lite prompt should include correct structure', async () => {
it('lite prompt should include correct structure', () => {
const feature = { id: 'test', planningMode: 'lite' as const };
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Goal');
expect(result).toContain('Approach');
expect(result).toContain('Files to Touch');
@@ -301,9 +298,9 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('Risks');
});
it('spec prompt should include task format instructions', async () => {
it('spec prompt should include task format instructions', () => {
const feature = { id: 'test', planningMode: 'spec' as const };
const result = await getPlanningPromptPrefix(service, feature);
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem');
expect(result).toContain('Solution');
expect(result).toContain('Acceptance Criteria');
@@ -312,13 +309,13 @@ describe('auto-mode-service.ts - Planning Mode', () => {
expect(result).toContain('Verification');
});
it('full prompt should include phases', async () => {
it('full prompt should include phases', () => {
const feature = { id: 'test', planningMode: 'full' as const };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('1. **Problem Statement**');
expect(result).toContain('2. **User Story**');
expect(result).toContain('4. **Technical Context**');
expect(result).toContain('5. **Non-Goals**');
const result = getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem Statement');
expect(result).toContain('User Story');
expect(result).toContain('Technical Context');
expect(result).toContain('Non-Goals');
expect(result).toContain('Phase 1');
expect(result).toContain('Phase 2');
expect(result).toContain('Phase 3');

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import type { Feature } from '@automaker/types';
describe('auto-mode-service.ts', () => {
let service: AutoModeService;
@@ -67,252 +66,4 @@ describe('auto-mode-service.ts', () => {
expect(runningCount).toBe(0);
});
});
describe('getRunningAgents', () => {
// Helper to access private runningFeatures Map
const getRunningFeaturesMap = (svc: AutoModeService) =>
(svc as any).runningFeatures as Map<
string,
{ featureId: string; projectPath: string; isAutoMode: boolean }
>;
// Helper to get the featureLoader and mock its get method
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { get: mockFn };
};
it('should return empty array when no agents are running', async () => {
const result = await service.getRunningAgents();
expect(result).toEqual([]);
});
it('should return running agents with basic info when feature data is not available', async () => {
// Arrange: Add a running feature to the Map
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123',
projectPath: '/test/project/path',
isAutoMode: true,
});
// Mock featureLoader.get to return null (feature not found)
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-123',
projectPath: '/test/project/path',
projectName: 'path',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should return running agents with title and description when feature data is available', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-456', {
featureId: 'feature-456',
projectPath: '/home/user/my-project',
isAutoMode: false,
});
const mockFeature: Partial<Feature> = {
id: 'feature-456',
title: 'Implement user authentication',
description: 'Add login and signup functionality',
category: 'auth',
};
const getMock = vi.fn().mockResolvedValue(mockFeature);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-456',
projectPath: '/home/user/my-project',
projectName: 'my-project',
isAutoMode: false,
title: 'Implement user authentication',
description: 'Add login and signup functionality',
});
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
});
it('should handle multiple running agents', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
runningFeaturesMap.set('feature-2', {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const getMock = vi
.fn()
.mockResolvedValueOnce({
id: 'feature-1',
title: 'Feature One',
description: 'Description one',
})
.mockResolvedValueOnce({
id: 'feature-2',
title: 'Feature Two',
description: 'Description two',
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(2);
expect(getMock).toHaveBeenCalledTimes(2);
});
it('should silently handle errors when fetching feature data', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-error', {
featureId: 'feature-error',
projectPath: '/project-error',
isAutoMode: true,
});
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
mockFeatureLoaderGet(service, getMock);
// Act - should not throw
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-error',
projectPath: '/project-error',
projectName: 'project-error',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should handle feature with title but no description', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-title-only', {
featureId: 'feature-title-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-title-only',
title: 'Only Title',
// description is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBe('Only Title');
expect(result[0].description).toBeUndefined();
});
it('should handle feature with description but no title', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-desc-only', {
featureId: 'feature-desc-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-desc-only',
description: 'Only description, no title',
// title is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBeUndefined();
expect(result[0].description).toBe('Only description, no title');
});
it('should extract projectName from nested paths correctly', async () => {
// Arrange
const runningFeaturesMap = getRunningFeaturesMap(service);
runningFeaturesMap.set('feature-nested', {
featureId: 'feature-nested',
projectPath: '/home/user/workspace/projects/my-awesome-project',
isAutoMode: true,
});
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].projectName).toBe('my-awesome-project');
});
it('should fetch feature data in parallel for multiple agents', async () => {
// Arrange: Add multiple running features
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: i % 2 === 0,
});
}
// Track call order
const callOrder: string[] = [];
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
callOrder.push(featureId);
// Simulate async delay to verify parallel execution
await new Promise((resolve) => setTimeout(resolve, 10));
return { id: featureId, title: `Title for ${featureId}` };
});
mockFeatureLoaderGet(service, getMock);
// Act
const startTime = Date.now();
const result = await service.getRunningAgents();
const duration = Date.now() - startTime;
// Assert
expect(result).toHaveLength(5);
expect(getMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms (one batch)
// If sequential, it would be ~50ms (5 * 10ms)
// Allow some buffer for execution overhead
expect(duration).toBeLessThan(40);
});
});
});

View File

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

43
apps/ui/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# Automaker UI
# Multi-stage build for minimal production image
# Build stage
FROM node:20-alpine AS builder
# Install build dependencies
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/ui/package*.json ./apps/ui/
COPY scripts ./scripts
# Install dependencies (skip electron postinstall)
RUN npm ci --workspace=apps/ui --ignore-scripts
# Copy source
COPY apps/ui ./apps/ui
# Build for web (skip electron)
# VITE_SERVER_URL tells the UI where to find the API server
# Using localhost:3008 since both containers expose ports to the host
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=http://localhost:3008
ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build --workspace=apps/ui
# Production stage - serve with nginx
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/apps/ui/dist /usr/share/nginx/html
# Copy nginx config for SPA routing
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -27,8 +27,8 @@
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
"postinstall": "electron-builder install-app-deps",
"preview": "vite preview",
"lint": "npx eslint",
"pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.mjs",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "cross-env vite",
@@ -44,7 +44,6 @@
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",

View File

@@ -3,24 +3,21 @@ import { defineConfig, devices } from '@playwright/test';
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
const mockAgent = true;
const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1, // Run sequentially to avoid auth conflicts with shared server
retries: process.env.CI ? 2 : 0,
workers: undefined,
reporter: 'html',
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
trace: 'on-failure',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
// Global setup - authenticate before each test
globalSetup: require.resolve('./tests/global-setup.ts'),
projects: [
{
name: 'chromium',
@@ -32,22 +29,16 @@ export default defineConfig({
: {
webServer: [
// Backend server - runs with mock agent enabled in CI
// Uses dev:test (no file watching) to avoid port conflicts from server restarts
{
command: `cd ../server && npm run dev:test`,
command: `cd ../server && npm run dev`,
url: `http://localhost:${serverPort}/api/health`,
// Don't reuse existing server to ensure we use the test API key
reuseExistingServer: false,
reuseExistingServer: true,
timeout: 60000,
env: {
...process.env,
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// Set a test API key for web mode authentication
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
// Hide the API key banner to reduce log noise
AUTOMAKER_HIDE_API_KEY: 'true',
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
},
},
@@ -60,8 +51,8 @@ export default defineConfig({
env: {
...process.env,
VITE_SKIP_SETUP: 'true',
// Always skip electron plugin during tests - prevents duplicate server spawning
VITE_SKIP_ELECTRON: 'true',
// Skip electron plugin in CI - no display available for Electron
VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined,
},
},
],

View File

@@ -1,44 +0,0 @@
/**
* Kill any existing servers on test ports before running tests
* This ensures the test server starts fresh with the correct API key
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008;
const UI_PORT = process.env.TEST_PORT || 3007;
async function killProcessOnPort(port) {
try {
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pids = stdout.trim().split('\n').filter(Boolean);
if (pids.length > 0) {
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
for (const pid of pids) {
try {
await execAsync(`kill -9 ${pid}`);
console.log(`[KillTestServers] Killed process ${pid}`);
} catch (error) {
// Process might have already exited
}
}
// Wait a moment for the port to be released
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
// No process on port, which is fine
}
}
async function main() {
console.log('[KillTestServers] Checking for existing test servers...');
await killProcessOnPort(Number(SERVER_PORT));
await killProcessOnPort(Number(UI_PORT));
console.log('[KillTestServers] Done');
}
main().catch(console.error);

View File

@@ -5,7 +5,6 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -26,15 +25,10 @@ const REFRESH_INTERVAL_SECONDS = 45;
export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
@@ -74,17 +68,14 @@ export function ClaudeUsagePopover() {
[setClaudeUsage]
);
// Auto-fetch on mount if data is stale (only if CLI is verified)
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isStale && isCliVerified) {
if (isStale) {
fetchUsage(true);
}
}, [isStale, isCliVerified, fetchUsage]);
}, [isStale, fetchUsage]);
useEffect(() => {
// Skip if CLI is not verified
if (!isCliVerified) return;
// Initial fetch when opened
if (open) {
if (!claudeUsage || isStale) {
@@ -103,7 +94,7 @@ export function ClaudeUsagePopover() {
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
}, [open, claudeUsage, isStale, fetchUsage]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {

View File

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

View File

@@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import { Sidebar } from './sidebar';
// TopHeader removed from layout to be view-specific
interface AppLayoutProps {
children: ReactNode;
}
export function AppLayout({ children }: AppLayoutProps) {
return (
<div className="flex h-screen w-full relative selection:bg-brand-cyan selection:text-black font-sans bg-dark-950 overflow-hidden">
{/* Ambient Background */}
<div
className="fixed bottom-[-25%] left-[-15%] w-[1000px] h-[1000px] opacity-80 pointer-events-none z-0 blob-rainbow"
style={{
background:
'radial-gradient(circle at center, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.12) 30%, rgba(249, 115, 22, 0.08) 60%, transparent 80%)',
filter: 'blur(100px)',
}}
></div>
<div
className="fixed top-[-20%] right-[-10%] w-[700px] h-[700px] pointer-events-none z-0"
style={{
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%)',
filter: 'blur(100px)',
}}
></div>
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative z-10 h-full">
<div className="flex-1 overflow-hidden relative">{children}</div>
</main>
</div>
);
}

View File

@@ -1,372 +1,213 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import React from 'react';
import {
Code2,
PanelLeft,
Plus,
Folder,
Bell,
FolderOpen,
MoreVertical,
LayoutGrid,
Bot,
FileJson,
BookOpen,
UserCircle,
TerminalSquare,
Book,
Activity,
Settings,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
// Local imports from subfolder
import {
CollapseToggleButton,
SidebarHeader,
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
useSpecRegeneration,
useNavigation,
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
import { Link, useLocation } from '@tanstack/react-router';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
// Setup dialog state and handlers
const {
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
} = useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
});
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
// Running agents count
const { runningAgentsCount } = useRunningAgents();
// Unviewed validations count
const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject);
// Trash operations
const {
activeTrashId,
isEmptyingTrash,
handleRestoreProject,
handleDeleteProjectFromDisk,
handleEmptyTrash,
} = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
});
// Spec regeneration events
useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
});
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
});
} else {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
} catch (error) {
console.error('[Sidebar] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
});
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
return (
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
{/* Logo */}
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
<div className="text-brand-cyan relative flex items-center justify-center">
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
<Code2 className="w-6 h-6 relative z-10" />
</div>
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
<button className="ml-auto text-slate-600 hover:text-white transition">
<PanelLeft className="w-4 h-4" />
</button>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* Top Actions */}
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
<div className="grid grid-cols-4 gap-2">
<button className="col-span-2 bg-dark-850/60 hover:bg-dark-700 text-slate-200 py-2 px-3 rounded-lg border border-white/5 flex items-center justify-center gap-2 transition text-xs font-medium shadow-lg shadow-black/20 group">
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition">
<Folder className="w-3.5 h-3.5" />
<span className="ml-1 text-[10px]">0</span>
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition relative">
<Bell className="w-3.5 h-3.5" />
<span className="absolute top-2 right-2.5 w-1.5 h-1.5 bg-brand-red rounded-full ring-2 ring-dark-850"></span>
</button>
</div>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* Project Selector */}
<div className="bg-dark-850/40 border border-white/5 rounded-xl p-1 flex items-center justify-between cursor-pointer hover:border-white/10 hover:bg-dark-850/60 transition group">
<div className="flex items-center gap-3 px-2 py-1.5">
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
<span className="text-white font-medium text-sm">test case 1</span>
</div>
<div className="flex items-center gap-1 pr-1">
<span className="w-5 h-5 rounded bg-dark-700 flex items-center justify-center text-[10px] text-slate-400 font-bold border border-white/5">
P
</span>
<MoreVertical className="w-4 h-4 text-slate-500" />
</div>
</div>
</div>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Navigation */}
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
{/* Project Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Project
</h3>
<nav className="space-y-0.5">
<NavItem
to="/"
icon={<LayoutGrid className="w-4 h-4" />}
label="Kanban Board"
shortcut="L"
isActive={location.pathname === '/' || location.pathname === '/board'}
/>
<NavItem
to="/agents"
icon={<Bot className="w-4 h-4" />}
label="Agent Runner"
shortcut="A"
isActive={location.pathname.startsWith('/agents')}
/>
</nav>
</div>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Tools Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Tools
</h3>
<nav className="space-y-0.5">
<NavItem
to="/spec"
icon={<FileJson className="w-4 h-4" />}
label="Spec Editor"
shortcut="D"
isActive={location.pathname.startsWith('/spec')}
/>
<NavItem
to="/context"
icon={<BookOpen className="w-4 h-4" />}
label="Context"
shortcut="C"
isActive={location.pathname.startsWith('/context')}
/>
<NavItem
to="/profiles"
icon={<UserCircle className="w-4 h-4" />}
label="AI Profiles"
shortcut="H"
isActive={location.pathname.startsWith('/profiles')}
/>
<NavItem
to="/terminal"
icon={<TerminalSquare className="w-4 h-4" />}
label="Terminal"
shortcut="T"
isActive={location.pathname.startsWith('/terminal')}
/>
</nav>
</div>
</div>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Footer */}
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
<Link
to="/wiki"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<Book className="w-4 h-4" />
<span className="text-sm">Wiki</span>
</Link>
<Link
to="/running-agents"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Activity className="w-4 h-4 text-brand-cyan" />
<span className="text-sm">Running Agents</span>
</div>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-cyan text-[10px] text-black font-bold shadow-glow-cyan">
3
</span>
</Link>
<Link
to="/settings"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Settings className="w-4 h-4" />
<span className="text-sm">Settings</span>
</div>
<span className="text-[10px] bg-dark-700 text-slate-500 px-1.5 py-0.5 rounded font-mono border border-white/5">
S
</span>
</Link>
</div>
</aside>
);
}
function NavItem({
to,
icon,
label,
shortcut,
isActive,
}: {
to: string;
icon: React.ReactNode;
label: string;
shortcut: string;
isActive: boolean;
}) {
return (
<Link
to={to}
className={cn(
'flex items-center justify-between px-6 py-2.5 transition group border-l-[2px]',
isActive
? 'nav-item-active bg-gradient-to-r from-brand-cyan/10 to-transparent border-brand-cyan text-brand-cyan-hover'
: 'text-slate-400 hover:text-white hover:bg-white/5 border-transparent'
)}
>
<div className="flex items-center gap-3">
<span className={cn(isActive ? 'text-brand-cyan' : 'group-hover:text-slate-300')}>
{icon}
</span>
<span className="text-sm font-medium">{label}</span>
</div>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-mono border',
isActive
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20'
: 'bg-dark-700 text-slate-500 border-white/5 group-hover:text-slate-300'
)}
>
{shortcut}
</span>
</Link>
);
}

View File

@@ -78,29 +78,14 @@ export function SidebarNavigation({
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<div className="relative">
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
>
{item.count > 99 ? '99' : item.count}
</span>
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
</div>
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
@@ -109,21 +94,7 @@ export function SidebarNavigation({
>
{item.label}
</span>
{/* Count badge */}
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99+' : item.count}
</span>
)}
{item.shortcut && sidebarOpen && !item.count && (
{item.shortcut && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',

View File

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

View File

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

View File

@@ -44,8 +44,6 @@ interface UseNavigationProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
}
export function useNavigation({
@@ -63,7 +61,6 @@ export function useNavigation({
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -172,7 +169,6 @@ export function useNavigation({
id: 'github-issues',
label: 'Issues',
icon: CircleDot,
count: unviewedValidationsCount,
},
{
id: 'github-prs',
@@ -184,15 +180,7 @@ export function useNavigation({
}
return sections;
}, [
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
hasGitHubRemote,
unviewedValidationsCount,
]);
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {

View File

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

View File

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

View File

@@ -1,82 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import type { Project, StoredValidation } from '@/lib/electron';
/**
* Hook to track the count of unviewed (fresh) issue validations for a project.
* Also provides a function to decrement the count when a validation is viewed.
*/
export function useUnviewedValidations(currentProject: Project | null) {
const [count, setCount] = useState(0);
const projectPathRef = useRef<string | null>(null);
// Keep project path in ref for use in async functions
useEffect(() => {
projectPathRef.current = currentProject?.path ?? null;
}, [currentProject?.path]);
// Fetch and update count from server
const fetchUnviewedCount = useCallback(async () => {
const projectPath = projectPathRef.current;
if (!projectPath) return;
try {
const api = getElectronAPI();
if (api.github?.getValidations) {
const result = await api.github.getValidations(projectPath);
if (result.success && result.validations) {
const unviewed = result.validations.filter((v: StoredValidation) => {
if (v.viewedAt) return false;
// Check if not stale (< 24 hours)
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
return hoursSince <= 24;
});
// Only update count if we're still on the same project (guard against race condition)
if (projectPathRef.current === projectPath) {
setCount(unviewed.length);
}
}
}
} catch (err) {
console.error('[useUnviewedValidations] Failed to load count:', err);
}
}, []);
// Load initial count and subscribe to events
useEffect(() => {
if (!currentProject?.path) {
setCount(0);
return;
}
// Load initial count
fetchUnviewedCount();
// Subscribe to validation events to update count
const api = getElectronAPI();
if (api.github?.onValidationEvent) {
const unsubscribe = api.github.onValidationEvent((event) => {
if (event.projectPath === currentProject.path) {
if (event.type === 'issue_validation_complete') {
// New validation completed - refresh count from server for consistency
fetchUnviewedCount();
} else if (event.type === 'issue_validation_viewed') {
// Validation was viewed - refresh count from server for consistency
fetchUnviewedCount();
}
}
});
return () => unsubscribe();
}
}, [currentProject?.path, fetchUnviewedCount]);
// Function to decrement count when a validation is viewed
const decrementCount = useCallback(() => {
setCount((prev) => Math.max(0, prev - 1));
}, []);
// Expose refreshCount as an alias to fetchUnviewedCount for external use
const refreshCount = fetchUnviewedCount;
return { count, decrementCount, refreshCount };
}

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