mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Compare commits
190 Commits
refactor/a
...
6408f514a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6408f514a4 | ||
|
|
6b97219f55 | ||
|
|
09a4d3f15a | ||
|
|
51e9a23ba1 | ||
|
|
0330c70261 | ||
|
|
e7504b247f | ||
|
|
9305ecc242 | ||
|
|
2f071a1ba3 | ||
|
|
1d732916f1 | ||
|
|
629fd24d9f | ||
|
|
72cb942788 | ||
|
|
91bff21d58 | ||
|
|
dfa719079f | ||
|
|
28becb177b | ||
|
|
f785f1204b | ||
|
|
f3edfbf24e | ||
|
|
3ddf26f666 | ||
|
|
c81ea768a7 | ||
|
|
0e020f7e4a | ||
|
|
0a5540c9a2 | ||
|
|
7df2182818 | ||
|
|
ee52333636 | ||
|
|
47bd7a76cf | ||
|
|
ae10dea2bf | ||
|
|
be4153c374 | ||
|
|
a144a63c51 | ||
|
|
205f662022 | ||
|
|
53d07fefb8 | ||
|
|
2d907938cc | ||
|
|
15ca1eb6d3 | ||
|
|
4ee160fae4 | ||
|
|
4ba0026aa1 | ||
|
|
983eb21faa | ||
|
|
df9a6314da | ||
|
|
6903d3c508 | ||
|
|
5c441f2313 | ||
|
|
00f9891237 | ||
|
|
d30296d559 | ||
|
|
e6e04d57bc | ||
|
|
829c16181b | ||
|
|
13261b7e8c | ||
|
|
854ba6ec74 | ||
|
|
bddf1a4bf8 | ||
|
|
887e2ea76b | ||
|
|
dd4c738e91 | ||
|
|
43c19c70ca | ||
|
|
627580a8f0 | ||
|
|
a2d5c1d546 | ||
|
|
6b9946df95 | ||
|
|
cb99c4b4e8 | ||
|
|
9af63bc1ef | ||
|
|
17a99a0e20 | ||
|
|
f4e87d4c25 | ||
|
|
c7f515adde | ||
|
|
1df778a9db | ||
|
|
cb44f8a717 | ||
|
|
7fcf3c1e1f | ||
|
|
de021f96bf | ||
|
|
8bb10632b1 | ||
|
|
06ef4f883f | ||
|
|
7e84591ef1 | ||
|
|
efcdd849b9 | ||
|
|
dee770c2ab | ||
|
|
f7b3f75163 | ||
|
|
b5ad77b0f9 | ||
|
|
98b925b821 | ||
|
|
a09a2c76ae | ||
|
|
b9653d6338 | ||
|
|
44ef2084cf | ||
|
|
57446b4fba | ||
|
|
fa799d3cb5 | ||
|
|
78ec389477 | ||
|
|
f06088a062 | ||
|
|
8af1b8bd08 | ||
|
|
d5340fd1a4 | ||
|
|
aa940d44ff | ||
|
|
381698b048 | ||
|
|
30fce3f746 | ||
|
|
4a8c6b0eba | ||
|
|
416ef3a394 | ||
|
|
2805c0ea53 | ||
|
|
727a7a5b9d | ||
|
|
46dd219d15 | ||
|
|
67dd628115 | ||
|
|
ab5d6a0e54 | ||
|
|
0b03e70f1d | ||
|
|
434792a2ef | ||
|
|
462dbf1522 | ||
|
|
eed5e20438 | ||
|
|
bea26a6b61 | ||
|
|
e9802ac00c | ||
|
|
41014f6ab6 | ||
|
|
ac2e8cfa88 | ||
|
|
7d5bc722fa | ||
|
|
7765a12868 | ||
|
|
dfe6920df9 | ||
|
|
525b2f82b6 | ||
|
|
f459b73cb5 | ||
|
|
a935229031 | ||
|
|
a3a5c9e2cb | ||
|
|
1662c6bf0b | ||
|
|
a08ba1b517 | ||
|
|
8226699734 | ||
|
|
d4439fafa0 | ||
|
|
6f1325f3ee | ||
|
|
d4f68b659b | ||
|
|
ad6ce738b4 | ||
|
|
67ebf8c14b | ||
|
|
8ed13564f6 | ||
|
|
09507bff67 | ||
|
|
c70344156d | ||
|
|
8542a32f4f | ||
|
|
0745832d1e | ||
|
|
0f0f5159d2 | ||
|
|
bcc854234c | ||
|
|
5ffbfb3217 | ||
|
|
7c89923a6e | ||
|
|
63b1a353d9 | ||
|
|
49bdaaae71 | ||
|
|
28224e1051 | ||
|
|
df10bcd6df | ||
|
|
0ed4494992 | ||
|
|
43309e383f | ||
|
|
efd4284c10 | ||
|
|
473f935c90 | ||
|
|
7fd3d61a59 | ||
|
|
7bc1f68699 | ||
|
|
ade22ef258 | ||
|
|
31f8afc115 | ||
|
|
071af1b5c3 | ||
|
|
1b32a6bc3a | ||
|
|
a0484624b7 | ||
|
|
0383f85507 | ||
|
|
1a7dd5d1eb | ||
|
|
afa60399dc | ||
|
|
1b39e25497 | ||
|
|
828d0a0148 | ||
|
|
18624d12ce | ||
|
|
71a0309a0b | ||
|
|
e0f785aa99 | ||
|
|
2aa156ecbf | ||
|
|
94a8e09516 | ||
|
|
78072550c7 | ||
|
|
0cd149f2e3 | ||
|
|
2e577bb230 | ||
|
|
4f00b41cb0 | ||
|
|
ba45587a0a | ||
|
|
4912d37990 | ||
|
|
b24839bc49 | ||
|
|
e3a1c8c312 | ||
|
|
8f245e7757 | ||
|
|
cbb45b6612 | ||
|
|
25fa6fd616 | ||
|
|
ec5179eee9 | ||
|
|
2fac438cde | ||
|
|
5dca97dab4 | ||
|
|
58facb114c | ||
|
|
8387b7669d | ||
|
|
18fd1c6caa | ||
|
|
6029e95403 | ||
|
|
1eb28206c5 | ||
|
|
bc9dae0322 | ||
|
|
3bcdc883e6 | ||
|
|
c92c8e96b7 | ||
|
|
b73ef9f801 | ||
|
|
70fc03431c | ||
|
|
a0ea65d483 | ||
|
|
ef544e70c9 | ||
|
|
152cf00735 | ||
|
|
094f0809d7 | ||
|
|
61d43106c8 | ||
|
|
9c304eeec3 | ||
|
|
3563dd55da | ||
|
|
220c8e4ddf | ||
|
|
f97453484f | ||
|
|
835ffe3185 | ||
|
|
3b361cb0b9 | ||
|
|
d06d25b1b5 | ||
|
|
84570842d3 | ||
|
|
63cae19aec | ||
|
|
c9e721bda7 | ||
|
|
d4b7a0c57d | ||
|
|
0b6e84ec6e | ||
|
|
e9c2afcc02 | ||
|
|
88864ad6bc | ||
|
|
0aef72540e | ||
|
|
aad3ff2cdf | ||
|
|
3ccea7a67b | ||
|
|
b37a287c9c | ||
|
|
45f6f17eb0 |
18
.github/actions/setup-project/action.yml
vendored
18
.github/actions/setup-project/action.yml
vendored
@@ -25,17 +25,24 @@ runs:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
shell: bash
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Auto-fix SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
# Auto-fix any git+ssh:// URLs in package-lock.json before linting
|
||||
# This handles cases where npm reintroduces SSH URLs for git dependencies
|
||||
run: node scripts/fix-lockfile-urls.mjs
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
@@ -45,6 +52,7 @@ runs:
|
||||
run: npm install --ignore-scripts --force
|
||||
|
||||
- name: Install Linux native bindings
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
|
||||
18
.github/workflows/e2e-tests.yml
vendored
18
.github/workflows/e2e-tests.yml
vendored
@@ -46,7 +46,8 @@ jobs:
|
||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||
|
||||
env:
|
||||
PORT: 3008
|
||||
PORT: 3108
|
||||
TEST_SERVER_PORT: 3108
|
||||
NODE_ENV: test
|
||||
# Use a deterministic API key so Playwright can log in reliably
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
@@ -81,13 +82,13 @@ jobs:
|
||||
|
||||
# Wait for health endpoint
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "Health check response:"
|
||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -111,11 +112,11 @@ jobs:
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||
lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
|
||||
echo ""
|
||||
echo "=== Health endpoint test ==="
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
|
||||
|
||||
# Kill the server process if it's still hanging
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
@@ -132,7 +133,8 @@ jobs:
|
||||
run: npm run test --workspace=apps/ui
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SERVER_URL: http://localhost:3108
|
||||
SERVER_URL: http://localhost:3108
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
@@ -147,7 +149,7 @@ jobs:
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -95,9 +95,11 @@ jobs:
|
||||
upload:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.release.draft == false
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -90,8 +90,15 @@ pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Fork-specific workflow files (should never be committed)
|
||||
DEVELOPMENT_WORKFLOW.md
|
||||
check-sync.sh
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
data/
|
||||
.codex/
|
||||
|
||||
# GSD planning docs (local-only)
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
|
||||
@@ -38,6 +38,18 @@ else
|
||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||
fi
|
||||
|
||||
# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed
|
||||
# This prevents CI failures from SSH URLs that npm introduces for git dependencies
|
||||
if git diff --cached --name-only | grep -q "^package-lock.json$"; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
if grep -q "git+ssh://" package-lock.json 2>/dev/null; then
|
||||
echo "Fixing git+ssh:// URLs in package-lock.json..."
|
||||
node scripts/fix-lockfile-urls.mjs
|
||||
git add package-lock.json
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run lint-staged - works with or without nvm
|
||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
|
||||
81
.planning/PROJECT.md
Normal file
81
.planning/PROJECT.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# AutoModeService Refactoring
|
||||
|
||||
## What This Is
|
||||
|
||||
A comprehensive refactoring of the `auto-mode-service.ts` file (5k+ lines) into smaller, focused services with clear boundaries. This is an architectural cleanup of accumulated technical debt from rapid development, breaking the "god object" anti-pattern into maintainable, debuggable modules.
|
||||
|
||||
## Core Value
|
||||
|
||||
All existing auto-mode functionality continues working — features execute, pipelines flow, merges complete — while the codebase becomes maintainable.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- Existing functionality that must be preserved -->
|
||||
|
||||
- ✓ Single feature execution with AI agent — existing
|
||||
- ✓ Concurrent execution with configurable limits — existing
|
||||
- ✓ Pipeline orchestration (backlog → in-progress → approval → verified) — existing
|
||||
- ✓ Git worktree isolation per feature — existing
|
||||
- ✓ Automatic merging of completed work — existing
|
||||
- ✓ Custom pipeline support — existing
|
||||
- ✓ Test runner integration — existing
|
||||
- ✓ Event streaming to frontend — existing
|
||||
|
||||
### Active
|
||||
|
||||
<!-- Refactoring goals -->
|
||||
|
||||
- [ ] No service file exceeds ~500 lines
|
||||
- [ ] Each service has single, clear responsibility
|
||||
- [ ] Service boundaries make debugging obvious
|
||||
- [ ] Changes to one service don't risk breaking unrelated features
|
||||
- [ ] Test coverage for critical paths
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- New auto-mode features — this is cleanup, not enhancement
|
||||
- UI changes — backend refactor only
|
||||
- Performance optimization — maintain current performance, don't optimize
|
||||
- Other service refactoring — focus on auto-mode-service.ts only
|
||||
|
||||
## Context
|
||||
|
||||
**Current state:** `apps/server/src/services/auto-mode-service.ts` is ~5700 lines handling:
|
||||
|
||||
- Worktree management (create, cleanup, track)
|
||||
- Agent/task execution coordination
|
||||
- Concurrency control and queue management
|
||||
- Pipeline state machine (column transitions)
|
||||
- Merge handling and conflict resolution
|
||||
- Event emission for real-time updates
|
||||
|
||||
**Technical environment:**
|
||||
|
||||
- Express 5 backend, TypeScript
|
||||
- Event-driven architecture via EventEmitter
|
||||
- WebSocket streaming to React frontend
|
||||
- Git worktrees via @automaker/git-utils
|
||||
- Minimal existing test coverage
|
||||
|
||||
**Codebase analysis:** See `.planning/codebase/` for full architecture, conventions, and existing patterns.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking changes**: Acceptable — other parts of the app can be updated to match new service interfaces
|
||||
- **Test coverage**: Currently minimal — must add tests during refactoring to catch regressions
|
||||
- **Incremental approach**: Required — can't do big-bang rewrite with everything critical
|
||||
- **Existing patterns**: Follow conventions in `.planning/codebase/CONVENTIONS.md`
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
| ------------------------- | --------------------------------------------------- | --------- |
|
||||
| Accept breaking changes | Allows cleaner interfaces, worth the migration cost | — Pending |
|
||||
| Add tests during refactor | No existing safety net, need to build one | — Pending |
|
||||
| Incremental extraction | Everything is critical, can't break it all at once | — Pending |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-01-27 after initialization_
|
||||
234
.planning/codebase/ARCHITECTURE.md
Normal file
234
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Monorepo with layered client-server architecture (Electron-first) and pluggable provider abstraction for AI models.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Event-driven communication via WebSocket between frontend and backend
|
||||
- Multi-provider AI model abstraction layer (Claude, Cursor, Codex, Gemini, OpenCode, Copilot)
|
||||
- Feature-centric workflow stored in `.automaker/` directories
|
||||
- Isolated git worktree execution for each feature
|
||||
- State management through Zustand stores with API persistence
|
||||
|
||||
## Layers
|
||||
|
||||
**Presentation Layer (UI):**
|
||||
|
||||
- Purpose: React 19 Electron/web frontend with TanStack Router file-based routing
|
||||
- Location: `apps/ui/src/`
|
||||
- Contains: Route components, view pages, custom React hooks, Zustand stores, API client
|
||||
- Depends on: @automaker/types, @automaker/utils, HTTP API backend
|
||||
- Used by: Electron main process (desktop), web browser (web mode)
|
||||
|
||||
**API Layer (Server):**
|
||||
|
||||
- Purpose: Express 5 backend exposing RESTful and WebSocket endpoints
|
||||
- Location: `apps/server/src/`
|
||||
- Contains: Route handlers, business logic services, middleware, provider adapters
|
||||
- Depends on: @automaker/types, @automaker/utils, @automaker/platform, Claude Agent SDK
|
||||
- Used by: UI frontend via HTTP/WebSocket
|
||||
|
||||
**Service Layer (Server):**
|
||||
|
||||
- Purpose: Business logic and domain operations
|
||||
- Location: `apps/server/src/services/`
|
||||
- Contains: AgentService, FeatureLoader, AutoModeService, SettingsService, DevServerService, etc.
|
||||
- Depends on: Providers, secure filesystem, feature storage
|
||||
- Used by: Route handlers
|
||||
|
||||
**Provider Abstraction (Server):**
|
||||
|
||||
- Purpose: Unified interface for different AI model providers
|
||||
- Location: `apps/server/src/providers/`
|
||||
- Contains: ProviderFactory, specific provider implementations (ClaudeProvider, CursorProvider, CodexProvider, GeminiProvider, OpencodeProvider, CopilotProvider)
|
||||
- Depends on: @automaker/types, provider SDKs
|
||||
- Used by: AgentService
|
||||
|
||||
**Shared Library Layer:**
|
||||
|
||||
- Purpose: Type definitions and utilities shared across apps
|
||||
- Location: `libs/`
|
||||
- Contains: @automaker/types, @automaker/utils, @automaker/platform, @automaker/prompts, @automaker/model-resolver, @automaker/dependency-resolver, @automaker/git-utils, @automaker/spec-parser
|
||||
- Depends on: None (types has no external deps)
|
||||
- Used by: All apps and services
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Feature Execution Flow:**
|
||||
|
||||
1. User creates/updates feature via UI (`apps/ui/src/`)
|
||||
2. UI sends HTTP request to backend (`POST /api/features`)
|
||||
3. Server route handler invokes FeatureLoader to persist to `.automaker/features/{featureId}/`
|
||||
4. When executing, AgentService loads feature, creates isolated git worktree via @automaker/git-utils
|
||||
5. AgentService invokes ProviderFactory to get appropriate AI provider (Claude, Cursor, etc.)
|
||||
6. Provider executes with context from CLAUDE.md files via @automaker/utils loadContextFiles()
|
||||
7. Server emits events via EventEmitter throughout execution
|
||||
8. Events stream to frontend via WebSocket
|
||||
9. UI updates stores and renders real-time progress
|
||||
10. Feature results persist back to `.automaker/features/` with generated agent-output.md
|
||||
|
||||
**State Management:**
|
||||
|
||||
**Frontend State (Zustand):**
|
||||
|
||||
- `app-store.ts`: Global app state (projects, features, settings, boards, themes)
|
||||
- `setup-store.ts`: First-time setup wizard flow
|
||||
- `ideation-store.ts`: Ideation feature state
|
||||
- `test-runners-store.ts`: Test runner configurations
|
||||
- Settings now persist via API (`/api/settings`) rather than localStorage (see use-settings-sync.ts)
|
||||
|
||||
**Backend State (Services):**
|
||||
|
||||
- SettingsService: Global and project-specific settings (in-memory with file persistence)
|
||||
- AgentService: Active agent sessions and conversation history
|
||||
- FeatureLoader: Feature data model operations
|
||||
- DevServerService: Development server logs
|
||||
- EventHistoryService: Persists event logs for replay
|
||||
|
||||
**Real-Time Updates (WebSocket):**
|
||||
|
||||
- Server EventEmitter emits TypedEvent (type + payload)
|
||||
- WebSocket handler subscribes to events and broadcasts to all clients
|
||||
- Frontend listens on multiple WebSocket subscriptions and updates stores
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Feature:**
|
||||
|
||||
- Purpose: Represents a development task/story with rich metadata
|
||||
- Location: @automaker/types → `libs/types/src/feature.ts`
|
||||
- Fields: id, title, description, status, images, tasks, priority, etc.
|
||||
- Stored: `.automaker/features/{featureId}/feature.json`
|
||||
|
||||
**Provider:**
|
||||
|
||||
- Purpose: Abstracts different AI model implementations
|
||||
- Location: `apps/server/src/providers/{provider}-provider.ts`
|
||||
- Interface: Common execute() method with consistent message format
|
||||
- Implementations: Claude, Cursor, Codex, Gemini, OpenCode, Copilot
|
||||
- Factory: ProviderFactory picks correct provider based on model ID
|
||||
|
||||
**Event:**
|
||||
|
||||
- Purpose: Real-time updates streamed to frontend
|
||||
- Location: @automaker/types → `libs/types/src/event.ts`
|
||||
- Format: { type: EventType, payload: unknown }
|
||||
- Examples: agent-started, agent-step, agent-complete, feature-updated, etc.
|
||||
|
||||
**AgentSession:**
|
||||
|
||||
- Purpose: Represents a conversation between user and AI agent
|
||||
- Location: @automaker/types → `libs/types/src/session.ts`
|
||||
- Contains: Messages (user + assistant), metadata, creation timestamp
|
||||
- Stored: `{DATA_DIR}/agent-sessions/{sessionId}.json`
|
||||
|
||||
**Settings:**
|
||||
|
||||
- Purpose: Configuration for global and per-project behavior
|
||||
- Location: @automaker/types → `libs/types/src/settings.ts`
|
||||
- Stored: Global in `{DATA_DIR}/settings.json`, per-project in `.automaker/settings.json`
|
||||
- Service: SettingsService in `apps/server/src/services/settings-service.ts`
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Server:**
|
||||
|
||||
- Location: `apps/server/src/index.ts`
|
||||
- Triggers: `npm run dev:server` or Docker startup
|
||||
- Responsibilities:
|
||||
- Initialize Express app with middleware
|
||||
- Create shared EventEmitter for WebSocket streaming
|
||||
- Bootstrap services (SettingsService, AgentService, FeatureLoader, etc.)
|
||||
- Mount API routes at `/api/*`
|
||||
- Create WebSocket servers for agent streaming and terminal sessions
|
||||
- Load and apply user settings (log level, request logging, etc.)
|
||||
|
||||
**UI (Web):**
|
||||
|
||||
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/src/app.tsx` (React component)
|
||||
- Triggers: `npm run dev:web` or `npm run build`
|
||||
- Responsibilities:
|
||||
- Initialize Zustand stores from API settings
|
||||
- Setup React Router with TanStack Router
|
||||
- Render root layout with sidebar and main content area
|
||||
- Handle authentication via verifySession()
|
||||
|
||||
**UI (Electron):**
|
||||
|
||||
- Location: `apps/ui/src/main.ts` (Vite entry), `apps/ui/electron/main-process.ts` (Electron main process)
|
||||
- Triggers: `npm run dev:electron`
|
||||
- Responsibilities:
|
||||
- Launch local server via node-pty
|
||||
- Create native Electron window
|
||||
- Bridge IPC between renderer and main process
|
||||
- Provide file system access via preload.ts APIs
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Layered error classification and user-friendly messaging
|
||||
|
||||
**Patterns:**
|
||||
|
||||
**Backend Error Handling:**
|
||||
|
||||
- Errors classified via `classifyError()` from @automaker/utils
|
||||
- Classification: ParseError, NetworkError, AuthenticationError, RateLimitError, etc.
|
||||
- Response format: `{ success: false, error: { type, message, code }, details? }`
|
||||
- Example: `apps/server/src/lib/error-handler.ts`
|
||||
|
||||
**Frontend Error Handling:**
|
||||
|
||||
- HTTP errors caught by api-fetch.ts with retry logic
|
||||
- WebSocket disconnects trigger reconnection with exponential backoff
|
||||
- Errors shown in toast notifications via `sonner` library
|
||||
- Validation errors caught and displayed inline in forms
|
||||
|
||||
**Agent Execution Errors:**
|
||||
|
||||
- AgentService wraps provider calls in try-catch
|
||||
- Aborts handled specially via `isAbortError()` check
|
||||
- Rate limit errors trigger cooldown before retry
|
||||
- Model-specific errors mapped to user guidance
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
|
||||
- Framework: @automaker/utils createLogger()
|
||||
- Pattern: `const logger = createLogger('ModuleName')`
|
||||
- Levels: ERROR, WARN, INFO, DEBUG (configurable via settings)
|
||||
- Output: stdout (dev), files (production)
|
||||
|
||||
**Validation:**
|
||||
|
||||
- File path validation: @automaker/platform initAllowedPaths() enforces restrictions
|
||||
- Model ID validation: @automaker/model-resolver resolveModelString()
|
||||
- JSON schema validation: Manual checks in route handlers (no JSON schema lib)
|
||||
- Authentication: Session token validation via validateWsConnectionToken()
|
||||
|
||||
**Authentication:**
|
||||
|
||||
- Frontend: Session token stored in httpOnly cookie
|
||||
- Backend: authMiddleware checks token on protected routes
|
||||
- WebSocket: validateWsConnectionToken() for upgrade requests
|
||||
- Providers: API keys stored encrypted in `{DATA_DIR}/credentials.json`
|
||||
|
||||
**Internationalization:**
|
||||
|
||||
- Not detected - strings are English-only
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Code splitting: File-based routing via TanStack Router
|
||||
- Lazy loading: React.lazy() in route components
|
||||
- Caching: React Query for HTTP requests (query-keys.ts defines cache strategy)
|
||||
- Image optimization: Automatic base64 encoding for agent context
|
||||
- State hydration: Settings loaded once at startup, synced via API
|
||||
|
||||
---
|
||||
|
||||
_Architecture analysis: 2026-01-27_
|
||||
245
.planning/codebase/CONCERNS.md
Normal file
245
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Loose Type Safety in Error Handling:**
|
||||
|
||||
- Issue: Multiple uses of `as any` type assertions bypass TypeScript safety, particularly in error context handling and provider responses
|
||||
- Files: `apps/server/src/providers/claude-provider.ts` (lines 318-322), `apps/server/src/lib/error-handler.ts`, `apps/server/src/routes/settings/routes/update-global.ts`
|
||||
- Impact: Errors could have unchecked properties; refactoring becomes risky without compiler assistance
|
||||
- Fix approach: Replace `as any` with proper type guards and discriminated unions; create helper functions for safe property access
|
||||
|
||||
**Missing Test Coverage for Critical Services:**
|
||||
|
||||
- Issue: Several core services explicitly excluded from test coverage thresholds due to integration complexity
|
||||
- Files: `apps/server/vitest.config.ts` (line 22), explicitly excluded: `claude-usage-service.ts`, `mcp-test-service.ts`, `cli-provider.ts`, `cursor-provider.ts`
|
||||
- Impact: Usage tracking, MCP integration, and CLI detection could break undetected; regression detection is limited
|
||||
- Fix approach: Create integration test fixtures for CLI providers; mock MCP SDK for mcp-test-service tests; add usage tracking unit tests with mocked API calls
|
||||
|
||||
**Unused/Stub TODO Item Processing:**
|
||||
|
||||
- Issue: TodoWrite tool implementation exists but is partially integrated; tool name constants scattered across codex provider
|
||||
- Files: `apps/server/src/providers/codex-tool-mapping.ts`, `apps/server/src/providers/codex-provider.ts`
|
||||
- Impact: Todo list updates may not synchronize properly with all providers; unclear which providers support TodoWrite
|
||||
- Fix approach: Consolidate tool name constants; add provider capability flags for todo support
|
||||
|
||||
**Electron Electron.ts Size and Complexity:**
|
||||
|
||||
- Issue: Single 3741-line file handles all Electron IPC, native bindings, and communication
|
||||
- Files: `apps/ui/src/lib/electron.ts`
|
||||
- Impact: Difficult to test; hard to isolate bugs; changes require full testing of all features; potential memory overhead from monolithic file
|
||||
- Fix approach: Split by responsibility (IPC, window management, file operations, debug tools); create separate bridge layers
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**API Key Management Incomplete for Gemini:**
|
||||
|
||||
- Symptoms: Gemini API key verification endpoint not implemented despite other providers having verification
|
||||
- Files: `apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts` (line 122)
|
||||
- Trigger: User tries to verify Gemini API key in settings
|
||||
- Workaround: Key verification skipped for Gemini; settings page still accepts and stores key
|
||||
|
||||
**Orphaned Features Detection Vulnerable to False Negatives:**
|
||||
|
||||
- Symptoms: Features marked as orphaned when branch matching logic doesn't account for all scenarios
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 5714-5773)
|
||||
- Trigger: Features that were manually switched branches or rebased
|
||||
- Workaround: Manual cleanup via feature deletion; branch comparison is basic name matching only
|
||||
|
||||
**Terminal Themes Incomplete:**
|
||||
|
||||
- Symptoms: Light theme themes (solarizedlight, github) map to same generic lightTheme; no dedicated implementations
|
||||
- Files: `apps/ui/src/config/terminal-themes.ts` (lines 593-594)
|
||||
- Trigger: User selects solarizedlight or github terminal theme
|
||||
- Workaround: Uses generic light theme instead of specific scheme; visual appearance doesn't match expectation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Process Environment Variable Exposure:**
|
||||
|
||||
- Risk: Child processes inherit all parent `process.env` including sensitive credentials (API keys, tokens)
|
||||
- Files: `apps/server/src/providers/cursor-provider.ts` (line 993), `apps/server/src/providers/codex-provider.ts` (line 1099)
|
||||
- Current mitigation: Dotenv provides isolation at app startup; selective env passing to some providers
|
||||
- Recommendations: Use explicit allowlists for env vars passed to child processes (only pass REQUIRED_KEYS); audit all spawn calls for env handling; document which providers need which credentials
|
||||
|
||||
**Unvalidated Provider Tool Input:**
|
||||
|
||||
- Risk: Tool input from CLI providers (Cursor, Copilot, Codex) is partially validated through Record<string, unknown> patterns; execution context could be escaped
|
||||
- Files: `apps/server/src/providers/codex-provider.ts` (lines 506-543), `apps/server/src/providers/tool-normalization.ts`
|
||||
- Current mitigation: Status enums validated; tool names checked against allow-lists in some providers
|
||||
- Recommendations: Implement comprehensive schema validation for all tool inputs before execution; use zod or similar for runtime validation; add security tests for injection patterns
|
||||
|
||||
**API Key Storage in Settings Files:**
|
||||
|
||||
- Risk: API keys stored in plaintext in `~/.automaker/settings.json` and `data/settings.json`; file permissions may not be restricted
|
||||
- Files: `apps/server/src/services/settings-service.ts`, uses `atomicWriteJson` without file permission enforcement
|
||||
- Current mitigation: Limited by file system permissions; Electron mode has single-user access
|
||||
- Recommendations: Encrypt sensitive settings fields (apiKeys, tokens); use OS credential stores (Keychain/Credential Manager) for production; add file permission checks on startup
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Synchronous Feature Loading at Startup:**
|
||||
|
||||
- Problem: All features loaded synchronously at project load; blocks UI with 1000+ features
|
||||
- Files: `apps/server/src/services/feature-loader.ts` (line 230 Promise.all, but synchronous enumeration)
|
||||
- Cause: Feature directory walk and JSON parsing is not paginated or lazy-loaded
|
||||
- Improvement path: Implement lazy loading with pagination (load first 50, fetch more on scroll); add caching layer with TTL; move to background indexing; add feature count limits with warnings
|
||||
|
||||
**Auto-Mode Concurrency at Max Can Exceed Rate Limits:**
|
||||
|
||||
- Problem: maxConcurrency = 10 can quickly exhaust Claude API rate limits if all features execute simultaneously
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (line 2931 Promise.all for concurrent agents)
|
||||
- Cause: No adaptive backoff; no API usage tracking before queuing; hint mentions reducing concurrency but doesn't enforce it
|
||||
- Improvement path: Integrate with claude-usage-service to check remaining quota before starting features; implement exponential backoff on 429 errors; add per-model rate limit tracking
|
||||
|
||||
**Terminal Session Memory Leak Risk:**
|
||||
|
||||
- Problem: Terminal sessions accumulate in memory; expired sessions not cleaned up reliably
|
||||
- Files: `apps/server/src/routes/terminal/common.ts` (line 66 cleanup runs every 5 minutes, but only for tokens)
|
||||
- Cause: Cleanup interval is arbitrary; session map not bounded; no session lifespan limit
|
||||
- Improvement path: Implement LRU eviction with max session count; reduce cleanup interval to 1 minute; add memory usage monitoring; auto-close idle sessions after 30 minutes
|
||||
|
||||
**Large File Content Loading Without Limits:**
|
||||
|
||||
- Problem: File content loaded entirely into memory; `describe-file.ts` truncates at 50KB but loads all content first
|
||||
- Files: `apps/server/src/routes/context/routes/describe-file.ts` (line 128)
|
||||
- Cause: Synchronous file read; no streaming; no check before reading large files
|
||||
- Improvement path: Check file size before reading; stream large files; add file size warnings; implement chunked processing for analysis
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Provider Factory Model Resolution:**
|
||||
|
||||
- Files: `apps/server/src/providers/provider-factory.ts`, `apps/server/src/providers/simple-query-service.ts`
|
||||
- Why fragile: Each provider interprets model strings differently; no central registry; model aliases resolved at multiple layers (model-resolver, provider-specific maps, CLI validation)
|
||||
- Safe modification: Add integration tests for each model alias per provider; create model capability matrix; centralize model validation before dispatch
|
||||
- Test coverage: No dedicated tests; relies on E2E; no isolated unit tests for model resolution
|
||||
|
||||
**WebSocket Session Authentication:**
|
||||
|
||||
- Files: `apps/server/src/lib/auth.ts` (line 40 setInterval), `apps/server/src/index.ts` (token validation per message)
|
||||
- Why fragile: Session tokens generated and validated at multiple points; no single source of truth; expiration is not atomic
|
||||
- Safe modification: Add tests for token expiration edge cases; ensure cleanup removes all references; log all auth failures
|
||||
- Test coverage: Auth middleware tested, but not session lifecycle
|
||||
|
||||
**Auto-Mode Feature State Machine:**
|
||||
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 465-600)
|
||||
- Why fragile: Multiple states (running, queued, completed, error) managed across different methods; no explicit state transition validation; error recovery is defensive (catches all, logs, continues)
|
||||
- Safe modification: Create explicit state enum with valid transitions; add invariant checks; unit test state transitions with all error cases
|
||||
- Test coverage: Gaps in error recovery paths; no tests for concurrent state changes
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Feature Count Scalability:**
|
||||
|
||||
- Current capacity: ~1000 features tested; UI performance degrades with pagination required
|
||||
- Limit: 10K+ features cause >5s load times; memory usage ~100MB for metadata alone
|
||||
- Scaling path: Implement feature database instead of file-per-feature; add ElasticSearch indexing for search; paginate API responses (50 per page); add feature archiving
|
||||
|
||||
**Concurrent Auto-Mode Executions:**
|
||||
|
||||
- Current capacity: maxConcurrency = 10 features; limited by Claude API rate limits
|
||||
- Limit: Rate limit hits at ~4-5 simultaneous features with extended context (100K+ tokens)
|
||||
- Scaling path: Implement token usage budgeting before feature start; queue features with estimated token cost; add provider-specific rate limit handling
|
||||
|
||||
**Terminal Session Count:**
|
||||
|
||||
- Current capacity: ~100 active terminal sessions per server
|
||||
- Limit: Memory grows unbounded; no session count limit enforced
|
||||
- Scaling path: Add max session count with least-recently-used eviction; implement session federation for distributed setup
|
||||
|
||||
**Worktree Disk Usage:**
|
||||
|
||||
- Current capacity: 10K worktrees (~20GB with typical repos)
|
||||
- Limit: `.worktrees` directory grows without cleanup; old worktrees accumulate
|
||||
- Scaling path: Add worktree TTL (delete if not used for 30 days); implement cleanup job; add quota warnings at 50/80% disk
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**node-pty Beta Version:**
|
||||
|
||||
- Risk: `node-pty@1.1.0-beta41` used for terminal emulation; beta status indicates possible instability
|
||||
- Impact: Terminal features could break on minor platform changes; no guarantees on bug fixes
|
||||
- Migration plan: Monitor releases for stable version; pin to specific commit if needed; test extensively on target platforms (macOS, Linux, Windows)
|
||||
|
||||
**@anthropic-ai/claude-agent-sdk 0.1.x:**
|
||||
|
||||
- Risk: Pre-1.0 version; SDK API may change in future releases; limited version stability guarantees
|
||||
- Impact: Breaking changes could require significant refactoring; feature additions in SDK may not align with Automaker roadmap
|
||||
- Migration plan: Pin to specific 0.1.x version; review SDK changelogs before upgrades; maintain SDK compatibility tests; consider fallback implementation for critical paths
|
||||
|
||||
**@openai/codex-sdk 0.77.x:**
|
||||
|
||||
- Risk: Codex model deprecated by OpenAI; SDK may be archived or unsupported
|
||||
- Impact: Codex provider could become non-functional; error messages may not be actionable
|
||||
- Migration plan: Monitor OpenAI roadmap for migration path; implement fallback to Claude for Codex requests; add deprecation warning in UI
|
||||
|
||||
**Express 5.2.x RC Stage:**
|
||||
|
||||
- Risk: Express 5 is still in release candidate phase (as of Node 22); full stability not guaranteed
|
||||
- Impact: Minor version updates could include breaking changes; middleware compatibility issues possible
|
||||
- Migration plan: Maintain compatibility layer for Express 5 API; test with latest major before release; document any version-specific workarounds
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**Persistent Session Storage:**
|
||||
|
||||
- Problem: Agent conversation sessions stored only in-memory; restart loses all chat history
|
||||
- Blocks: Long-running analysis across server restarts; session recovery not possible
|
||||
- Impact: Users must re-run entire analysis if server restarts; lost productivity
|
||||
|
||||
**Rate Limit Awareness:**
|
||||
|
||||
- Problem: No tracking of API usage relative to rate limits before executing features
|
||||
- Blocks: Predictable concurrent feature execution; users frequently hit rate limits unexpectedly
|
||||
- Impact: Feature execution fails with cryptic rate limit errors; poor user experience
|
||||
|
||||
**Feature Dependency Visualization:**
|
||||
|
||||
- Problem: Dependency-resolver package exists but no UI to visualize or manage dependencies
|
||||
- Blocks: Users cannot plan feature order; complex dependencies not visible
|
||||
- Impact: Features implemented in wrong order; blocking dependencies missed
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**CLI Provider Integration:**
|
||||
|
||||
- What's not tested: Actual CLI execution paths; environment setup; error recovery from CLI crashes
|
||||
- Files: `apps/server/src/providers/cli-provider.ts`, `apps/server/src/lib/cli-detection.ts`
|
||||
- Risk: Changes to CLI handling could break silently; detection logic not validated on target platforms
|
||||
- Priority: High - affects all CLI-based providers (Cursor, Copilot, Codex)
|
||||
|
||||
**Cursor Provider Platform-Specific Paths:**
|
||||
|
||||
- What's not tested: Windows/Linux Cursor installation detection; version directory parsing; APPDATA environment variable handling
|
||||
- Files: `apps/server/src/providers/cursor-provider.ts` (lines 267-498)
|
||||
- Risk: Platform-specific bugs not caught; Cursor detection fails on non-standard installations
|
||||
- Priority: High - Cursor is primary provider; platform differences critical
|
||||
|
||||
**Event Hook System State Changes:**
|
||||
|
||||
- What's not tested: Concurrent hook execution; cleanup on server shutdown; webhook delivery retries
|
||||
- Files: `apps/server/src/services/event-hook-service.ts` (line 248 Promise.allSettled)
|
||||
- Risk: Hooks may not execute in expected order; memory not cleaned up; webhooks lost on failure
|
||||
- Priority: Medium - affects automation workflows
|
||||
|
||||
**Error Classification for New Providers:**
|
||||
|
||||
- What's not tested: Each provider's unique error patterns mapped to ErrorType enum; new provider errors not classified
|
||||
- Files: `apps/server/src/lib/error-handler.ts` (lines 58-80), each provider error mapping
|
||||
- Risk: User sees generic "unknown error" instead of actionable message; categorization regresses with new providers
|
||||
- Priority: Medium - impacts user experience
|
||||
|
||||
**Feature State Corruption Scenarios:**
|
||||
|
||||
- What's not tested: Concurrent feature updates; partial writes with power loss; JSON parsing recovery
|
||||
- Files: `apps/server/src/services/feature-loader.ts`, `@automaker/utils` (atomicWriteJson)
|
||||
- Risk: Feature data corrupted on concurrent access; recovery incomplete; no validation before use
|
||||
- Priority: High - data loss risk
|
||||
|
||||
---
|
||||
|
||||
_Concerns audit: 2026-01-27_
|
||||
255
.planning/codebase/CONVENTIONS.md
Normal file
255
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
|
||||
- PascalCase for class/service files: `auto-mode-service.ts`, `feature-loader.ts`, `claude-provider.ts`
|
||||
- kebab-case for route/handler directories: `auto-mode/`, `features/`, `event-history/`
|
||||
- kebab-case for utility files: `secure-fs.ts`, `sdk-options.ts`, `settings-helpers.ts`
|
||||
- kebab-case for React components: `card.tsx`, `ansi-output.tsx`, `count-up-timer.tsx`
|
||||
- kebab-case for hooks: `use-board-background-settings.ts`, `use-responsive-kanban.ts`, `use-test-logs.ts`
|
||||
- kebab-case for store files: `app-store.ts`, `auth-store.ts`, `setup-store.ts`
|
||||
- Organized by functionality: `routes/features/routes/list.ts`, `routes/features/routes/get.ts`
|
||||
|
||||
**Functions:**
|
||||
|
||||
- camelCase for all function names: `createEventEmitter()`, `getAutomakerDir()`, `executeQuery()`
|
||||
- Verb-first for action functions: `buildPrompt()`, `classifyError()`, `loadContextFiles()`, `atomicWriteJson()`
|
||||
- Prefix with `use` for React hooks: `useBoardBackgroundSettings()`, `useAppStore()`, `useUpdateProjectSettings()`
|
||||
- Private methods prefixed with underscore: `_deleteOrphanedImages()`, `_migrateImages()`
|
||||
|
||||
**Variables:**
|
||||
|
||||
- camelCase for constants and variables: `featureId`, `projectPath`, `modelId`, `tempDir`
|
||||
- UPPER_SNAKE_CASE for global constants/enums: `DEFAULT_MAX_CONCURRENCY`, `DEFAULT_PHASE_MODELS`
|
||||
- Meaningful naming over abbreviations: `featureDirectory` not `fd`, `featureImages` not `img`
|
||||
- Prefixes for computed values: `is*` for booleans: `isClaudeModel`, `isContainerized`, `isAutoLoginEnabled`
|
||||
|
||||
**Types:**
|
||||
|
||||
- PascalCase for interfaces and types: `Feature`, `ExecuteOptions`, `EventEmitter`, `ProviderConfig`
|
||||
- Type files suffixed with `.d.ts`: `paths.d.ts`, `types.d.ts`
|
||||
- Organized by domain: `src/store/types/`, `src/lib/`
|
||||
- Re-export pattern from main package indexes: `export type { Feature };`
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
|
||||
- Tool: Prettier 3.7.4
|
||||
- Print width: 100 characters
|
||||
- Tab width: 2 spaces
|
||||
- Single quotes for strings
|
||||
- Semicolons required
|
||||
- Trailing commas: es5 (trailing in arrays/objects, not in params)
|
||||
- Arrow functions always include parentheses: `(x) => x * 2`
|
||||
- Line endings: LF (Unix)
|
||||
- Bracket spacing: `{ key: value }`
|
||||
|
||||
**Linting:**
|
||||
|
||||
- Tool: ESLint (flat config in `apps/ui/eslint.config.mjs`)
|
||||
- TypeScript ESLint plugin for `.ts`/`.tsx` files
|
||||
- Recommended configs: `@eslint/js`, `@typescript-eslint/recommended`
|
||||
- Unused variables warning with exception for parameters starting with `_`
|
||||
- Type assertions are allowed with description when using `@ts-ignore`
|
||||
- `@typescript-eslint/no-explicit-any` is warn-level (allow with caution)
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
|
||||
1. Node.js standard library: `import fs from 'fs/promises'`, `import path from 'path'`
|
||||
2. Third-party packages: `import { describe, it } from 'vitest'`, `import { Router } from 'express'`
|
||||
3. Shared packages (monorepo): `import type { Feature } from '@automaker/types'`, `import { createLogger } from '@automaker/utils'`
|
||||
4. Local relative imports: `import { FeatureLoader } from './feature-loader.js'`, `import * as secureFs from '../lib/secure-fs.js'`
|
||||
5. Type imports: separated with `import type { ... } from`
|
||||
|
||||
**Path Aliases:**
|
||||
|
||||
- `@/` - resolves to `./src` in both UI (`apps/ui/`) and server (`apps/server/`)
|
||||
- Shared packages prefixed with `@automaker/`:
|
||||
- `@automaker/types` - core TypeScript definitions
|
||||
- `@automaker/utils` - logging, errors, utilities
|
||||
- `@automaker/prompts` - AI prompt templates
|
||||
- `@automaker/platform` - path management, security, processes
|
||||
- `@automaker/model-resolver` - model alias resolution
|
||||
- `@automaker/dependency-resolver` - feature dependency ordering
|
||||
- `@automaker/git-utils` - git operations
|
||||
- Extensions: `.js` extension used in imports for ESM imports
|
||||
|
||||
**Import Rules:**
|
||||
|
||||
- Always import from shared packages, never from old paths
|
||||
- No circular dependencies between layers
|
||||
- Services import from providers and utilities
|
||||
- Routes import from services
|
||||
- Shared packages have strict dependency hierarchy (types → utils → platform → git-utils → server/ui)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Use `try-catch` blocks for async operations: wraps feature execution, file operations, git commands
|
||||
- Throw `new Error(message)` with descriptive messages: `throw new Error('already running')`, `throw new Error('Feature ${featureId} not found')`
|
||||
- Classify errors with `classifyError()` from `@automaker/utils` for categorization
|
||||
- Log errors with context using `createLogger()`: includes error classification
|
||||
- Return error info objects: `{ valid: false, errors: [...], warnings: [...] }`
|
||||
- Validation returns structured result: `{ valid, errors, warnings }` from provider `validateConfig()`
|
||||
|
||||
**Error Types:**
|
||||
|
||||
- Authentication errors: distinguish from validation/runtime errors
|
||||
- Path validation errors: caught by middleware in Express routes
|
||||
- File system errors: logged and recovery attempted with backups
|
||||
- SDK/API errors: classified and wrapped with context
|
||||
- Abort/cancellation errors: handled without stack traces (graceful shutdown)
|
||||
|
||||
**Error Messages:**
|
||||
|
||||
- Descriptive and actionable: not vague error codes
|
||||
- Include context when helpful: file paths, feature IDs, model names
|
||||
- User-friendly messages via `getUserFriendlyErrorMessage()` for client display
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:**
|
||||
|
||||
- Built-in `createLogger()` from `@automaker/utils`
|
||||
- Each module creates logger: `const logger = createLogger('ModuleName')`
|
||||
- Logger functions: `info()`, `warn()`, `error()`, `debug()`
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Log operation start and completion for significant operations
|
||||
- Log warnings for non-critical issues: file deletion failures, missing optional configs
|
||||
- Log errors with full error object: `logger.error('operation failed', error)`
|
||||
- Use module name as logger context: `createLogger('AutoMode')`, `createLogger('HttpClient')`
|
||||
- Avoid logging sensitive data (API keys, passwords)
|
||||
- No console.log in production code - use logger
|
||||
|
||||
**What to Log:**
|
||||
|
||||
- Feature execution start/completion
|
||||
- Error classification and recovery attempts
|
||||
- File operations (create, delete, migrate)
|
||||
- API calls and responses (in debug mode)
|
||||
- Async operation start/end
|
||||
- Warnings for deprecated patterns
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
|
||||
- Complex algorithms or business logic: explain the "why" not the "what"
|
||||
- Integration points: explain how modules communicate
|
||||
- Workarounds: explain the constraint that made the workaround necessary
|
||||
- Non-obvious performance implications
|
||||
- Edge cases and their handling
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
|
||||
- Used for public functions and classes
|
||||
- Document parameters with `@param`
|
||||
- Document return types with `@returns`
|
||||
- Document exceptions with `@throws`
|
||||
- Used for service classes: `/**\n * Module description\n * Manages: ...\n */`
|
||||
- Not required for simple getters/setters
|
||||
|
||||
**Example JSDoc Pattern:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Delete images that were removed from a feature
|
||||
*/
|
||||
private async deleteOrphanedImages(
|
||||
projectPath: string,
|
||||
oldPaths: Array<string>,
|
||||
newPaths: Array<string>
|
||||
): Promise<void> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
|
||||
- Keep functions under 100 lines when possible
|
||||
- Large services split into multiple related methods
|
||||
- Private helper methods extracted for complex logic
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- Use destructuring for object parameters with multiple properties
|
||||
- Document parameter types with TypeScript types
|
||||
- Optional parameters marked with `?`
|
||||
- Use `Record<string, unknown>` for flexible object parameters
|
||||
|
||||
**Return Values:**
|
||||
|
||||
- Explicit return types required for all public functions
|
||||
- Return structured objects for multiple values
|
||||
- Use `Promise<T>` for async functions
|
||||
- Async generators use `AsyncGenerator<T>` for streaming responses
|
||||
- Never implicitly return `undefined` (explicit return or throw)
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
|
||||
- Default export for class instantiation: `export default class FeatureLoader {}`
|
||||
- Named exports for functions: `export function createEventEmitter() {}`
|
||||
- Type exports separated: `export type { Feature };`
|
||||
- Barrel files (index.ts) re-export from module
|
||||
|
||||
**Barrel Files:**
|
||||
|
||||
- Used in routes: `routes/features/index.ts` creates router and exports
|
||||
- Used in stores: `store/index.ts` exports all store hooks
|
||||
- Pattern: group related exports for easier importing
|
||||
|
||||
**Service Classes:**
|
||||
|
||||
- Instantiated once and dependency injected
|
||||
- Public methods for API surface
|
||||
- Private methods prefixed with `_`
|
||||
- No static methods - prefer instances or functions
|
||||
- Constructor takes dependencies: `constructor(config?: ProviderConfig)`
|
||||
|
||||
**Provider Pattern:**
|
||||
|
||||
- Abstract base class: `BaseProvider` with abstract methods
|
||||
- Concrete implementations: `ClaudeProvider`, `CodexProvider`, `CursorProvider`
|
||||
- Common interface: `executeQuery()`, `detectInstallation()`, `validateConfig()`
|
||||
- Factory for instantiation: `ProviderFactory.create()`
|
||||
|
||||
## TypeScript Specific
|
||||
|
||||
**Strict Mode:** Always enabled globally
|
||||
|
||||
- `strict: true` in all tsconfigs
|
||||
- No implicit `any` - declare types explicitly
|
||||
- No optional chaining on base types without narrowing
|
||||
|
||||
**Type Definitions:**
|
||||
|
||||
- Interface for shapes: `interface Feature { ... }`
|
||||
- Type for unions/aliases: `type ModelAlias = 'haiku' | 'sonnet' | 'opus'`
|
||||
- Type guards for narrowing: `if (typeof x === 'string') { ... }`
|
||||
- Generic types for reusable patterns: `EventCallback<T>`
|
||||
|
||||
**React Specific (UI):**
|
||||
|
||||
- Functional components only
|
||||
- React 19 with hooks
|
||||
- Type props interface: `interface CardProps extends React.ComponentProps<'div'> { ... }`
|
||||
- Zustand stores for state management
|
||||
- Custom hooks for shared logic
|
||||
|
||||
---
|
||||
|
||||
_Convention analysis: 2026-01-27_
|
||||
232
.planning/codebase/INTEGRATIONS.md
Normal file
232
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**AI/LLM Providers:**
|
||||
|
||||
- Claude (Anthropic)
|
||||
- SDK: `@anthropic-ai/claude-agent-sdk` (0.1.76)
|
||||
- Auth: `ANTHROPIC_API_KEY` environment variable or stored credentials
|
||||
- Features: Extended thinking, vision/images, tools, streaming
|
||||
- Implementation: `apps/server/src/providers/claude-provider.ts`
|
||||
- Models: Opus 4.5, Sonnet 4, Haiku 4.5, and legacy models
|
||||
- Custom endpoints: `ANTHROPIC_BASE_URL` (optional)
|
||||
|
||||
- GitHub Copilot
|
||||
- SDK: `@github/copilot-sdk` (0.1.16)
|
||||
- Auth: GitHub OAuth (via `gh` CLI) or `GITHUB_TOKEN` environment variable
|
||||
- Features: Tools, streaming, runtime model discovery
|
||||
- Implementation: `apps/server/src/providers/copilot-provider.ts`
|
||||
- CLI detection: Searches for Copilot CLI binary
|
||||
- Models: Dynamic discovery via `copilot models list`
|
||||
|
||||
- OpenAI Codex/GPT-4
|
||||
- SDK: `@openai/codex-sdk` (0.77.0)
|
||||
- Auth: `OPENAI_API_KEY` environment variable or stored credentials
|
||||
- Features: Extended thinking, tools, sandbox execution
|
||||
- Implementation: `apps/server/src/providers/codex-provider.ts`
|
||||
- Execution modes: CLI (with sandbox) or SDK (direct API)
|
||||
- Models: Dynamic discovery via Codex CLI or SDK
|
||||
|
||||
- Google Gemini
|
||||
- Implementation: `apps/server/src/providers/gemini-provider.ts`
|
||||
- Features: Vision support, tools, streaming
|
||||
|
||||
- OpenCode (AWS/Azure/other)
|
||||
- Implementation: `apps/server/src/providers/opencode-provider.ts`
|
||||
- Supports: Amazon Bedrock, Azure models, local models
|
||||
- Features: Flexible provider architecture
|
||||
|
||||
- Cursor Editor
|
||||
- Implementation: `apps/server/src/providers/cursor-provider.ts`
|
||||
- Features: Integration with Cursor IDE
|
||||
|
||||
**Model Context Protocol (MCP):**
|
||||
|
||||
- SDK: `@modelcontextprotocol/sdk` (1.25.2)
|
||||
- Purpose: Connect AI agents to external tools and data sources
|
||||
- Implementation: `apps/server/src/services/mcp-test-service.ts`, `apps/server/src/routes/mcp/`
|
||||
- Configuration: Per-project in `.automaker/` directory
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
|
||||
- None - This codebase does NOT use traditional databases (SQL/NoSQL)
|
||||
- All data stored as files in local filesystem
|
||||
|
||||
**File Storage:**
|
||||
|
||||
- Local filesystem only
|
||||
- Locations:
|
||||
- `.automaker/` - Project-specific data (features, context, settings)
|
||||
- `./data/` or `DATA_DIR` env var - Global data (settings, credentials, sessions)
|
||||
- Secure file operations: `@automaker/platform` exports `secureFs` for restricted file access
|
||||
|
||||
**Caching:**
|
||||
|
||||
- In-memory caches for:
|
||||
- Model lists (Copilot, Codex runtime discovery)
|
||||
- Feature metadata
|
||||
- Project specifications
|
||||
- No distributed/persistent caching system
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:**
|
||||
|
||||
- Custom implementation (no third-party provider)
|
||||
- Authentication methods:
|
||||
1. Claude Max Plan (OAuth via Anthropic CLI)
|
||||
2. API Key mode (ANTHROPIC_API_KEY)
|
||||
3. Custom provider profiles with API keys
|
||||
4. Token-based session authentication for WebSocket
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `apps/server/src/lib/auth.ts` - Auth middleware
|
||||
- `apps/server/src/routes/auth/` - Auth routes
|
||||
- Session tokens for WebSocket connections
|
||||
- Credential storage in `./data/credentials.json` (encrypted/protected)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
|
||||
- None - No automatic error reporting service integrated
|
||||
- Custom error classification: `@automaker/utils` exports `classifyError()`
|
||||
- User-friendly error messages: `getUserFriendlyErrorMessage()`
|
||||
|
||||
**Logs:**
|
||||
|
||||
- Console logging with configurable levels
|
||||
- Logger: `@automaker/utils` exports `createLogger()`
|
||||
- Log levels: ERROR, WARN, INFO, DEBUG
|
||||
- Environment: `LOG_LEVEL` env var (optional)
|
||||
- Storage: Logs output to console/stdout (no persistent logging to files)
|
||||
|
||||
**Usage Tracking:**
|
||||
|
||||
- Claude API usage: `apps/server/src/services/claude-usage-service.ts`
|
||||
- Codex API usage: `apps/server/src/services/codex-usage-service.ts`
|
||||
- Tracks: Tokens, costs, rates
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
|
||||
- Local development: Node.js server + Vite dev server
|
||||
- Desktop: Electron application (macOS, Windows, Linux)
|
||||
- Web: Express server deployed to any Node.js host
|
||||
|
||||
**CI Pipeline:**
|
||||
|
||||
- GitHub Actions likely (`.github/workflows/` present in repo)
|
||||
- Testing: Playwright E2E, Vitest unit tests
|
||||
- Linting: ESLint
|
||||
- Formatting: Prettier
|
||||
|
||||
**Build Process:**
|
||||
|
||||
- `npm run build:packages` - Build shared packages
|
||||
- `npm run build` - Build web UI
|
||||
- `npm run build:electron` - Build Electron apps (platform-specific)
|
||||
- Electron Builder handles code signing and distribution
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
|
||||
- `ANTHROPIC_API_KEY` - For Claude provider (or provide in settings)
|
||||
- `OPENAI_API_KEY` - For Codex provider (optional)
|
||||
- `GITHUB_TOKEN` - For GitHub operations (optional)
|
||||
|
||||
**Optional env vars:**
|
||||
|
||||
- `PORT` - Server port (default 3008)
|
||||
- `HOST` - Server bind address (default 0.0.0.0)
|
||||
- `HOSTNAME` - Public hostname (default localhost)
|
||||
- `DATA_DIR` - Data storage directory (default ./data)
|
||||
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to directory
|
||||
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
|
||||
- `AUTOMAKER_AUTO_LOGIN` - Skip login prompt in dev
|
||||
|
||||
**Secrets location:**
|
||||
|
||||
- Runtime: Environment variables (`process.env`)
|
||||
- Stored: `./data/credentials.json` (file-based)
|
||||
- Retrieval: `apps/server/src/services/settings-service.ts`
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
|
||||
- WebSocket connections for real-time agent event streaming
|
||||
- GitHub webhook routes (optional): `apps/server/src/routes/github/`
|
||||
- Terminal WebSocket connections: `apps/server/src/routes/terminal/`
|
||||
|
||||
**Outgoing:**
|
||||
|
||||
- GitHub PRs: `apps/server/src/routes/worktree/routes/create-pr.ts`
|
||||
- Git operations: `@automaker/git-utils` handles commits, pushes
|
||||
- Terminal output streaming via WebSocket to clients
|
||||
- Event hooks: `apps/server/src/services/event-hook-service.ts`
|
||||
|
||||
## Credential Management
|
||||
|
||||
**API Keys Storage:**
|
||||
|
||||
- File: `./data/credentials.json`
|
||||
- Format: JSON with nested structure for different providers
|
||||
```json
|
||||
{
|
||||
"apiKeys": {
|
||||
"anthropic": "sk-...",
|
||||
"openai": "sk-...",
|
||||
"github": "ghp_..."
|
||||
}
|
||||
}
|
||||
```
|
||||
- Access: `SettingsService.getCredentials()` from `apps/server/src/services/settings-service.ts`
|
||||
- Security: File permissions should restrict to current user only
|
||||
|
||||
**Profile/Provider Configuration:**
|
||||
|
||||
- File: `./data/settings.json` (global) or `.automaker/settings.json` (per-project)
|
||||
- Stores: Alternative provider profiles, model mappings, sandbox settings
|
||||
- Types: `ClaudeApiProfile`, `ClaudeCompatibleProvider` from `@automaker/types`
|
||||
|
||||
## Third-Party Service Integration Points
|
||||
|
||||
**Git/GitHub:**
|
||||
|
||||
- `@automaker/git-utils` - Git operations (worktrees, commits, diffs)
|
||||
- Codex/Cursor providers can create GitHub PRs
|
||||
- GitHub CLI (`gh`) detection for Copilot authentication
|
||||
|
||||
**Terminal Access:**
|
||||
|
||||
- `node-pty` (1.1.0-beta41) - Pseudo-terminal interface
|
||||
- `TerminalService` manages terminal sessions
|
||||
- WebSocket streaming to frontend
|
||||
|
||||
**AI Models - Multi-Provider Abstraction:**
|
||||
|
||||
- `BaseProvider` interface: `apps/server/src/providers/base-provider.ts`
|
||||
- Factory pattern: `apps/server/src/providers/provider-factory.ts`
|
||||
- Allows swapping providers without changing agent logic
|
||||
- All providers implement: `executeQuery()`, `detectInstallation()`, `getAvailableModels()`
|
||||
|
||||
**Process Spawning:**
|
||||
|
||||
- `@automaker/platform` exports `spawnProcess()`, `spawnJSONLProcess()`
|
||||
- Codex CLI execution: JSONL output parsing
|
||||
- Copilot CLI execution: Subprocess management
|
||||
- Cursor IDE interaction: Process spawning for tool execution
|
||||
|
||||
---
|
||||
|
||||
_Integration audit: 2026-01-27_
|
||||
230
.planning/codebase/STACK.md
Normal file
230
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
|
||||
- TypeScript 5.9.3 - Used across all packages, apps, and configuration
|
||||
- JavaScript (Node.js) - Runtime execution for scripts and tooling
|
||||
|
||||
**Secondary:**
|
||||
|
||||
- YAML 2.7.0 - Configuration files
|
||||
- CSS/Tailwind CSS 4.1.18 - Frontend styling
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
|
||||
- Node.js 22.x (>=22.0.0 <23.0.0) - Required version, specified in `.nvmrc`
|
||||
|
||||
**Package Manager:**
|
||||
|
||||
- npm - Monorepo workspace management via npm workspaces
|
||||
- Lockfile: `package-lock.json` (present)
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core - Frontend:**
|
||||
|
||||
- React 19.2.3 - UI framework with hooks and concurrent features
|
||||
- Vite 7.3.0 - Build tool and dev server (`apps/ui/vite.config.ts`)
|
||||
- Electron 39.2.7 - Desktop application runtime (`apps/ui/package.json`)
|
||||
- TanStack Router 1.141.6 - File-based routing (React)
|
||||
- Zustand 5.0.9 - State management (lightweight alternative to Redux)
|
||||
- TanStack Query (React Query) 5.90.17 - Server state management
|
||||
|
||||
**Core - Backend:**
|
||||
|
||||
- Express 5.2.1 - HTTP server framework (`apps/server/package.json`)
|
||||
- WebSocket (ws) 8.18.3 - Real-time bidirectional communication
|
||||
- Claude Agent SDK (@anthropic-ai/claude-agent-sdk) 0.1.76 - AI provider integration
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Playwright 1.57.0 - End-to-end testing (`apps/ui` E2E tests)
|
||||
- Vitest 4.0.16 - Unit testing framework (runs on all packages and server)
|
||||
- @vitest/ui 4.0.16 - Visual test runner UI
|
||||
- @vitest/coverage-v8 4.0.16 - Code coverage reporting
|
||||
|
||||
**Build/Dev:**
|
||||
|
||||
- electron-builder 26.0.12 - Electron app packaging and distribution
|
||||
- @vitejs/plugin-react 5.1.2 - Vite React support
|
||||
- vite-plugin-electron 0.29.0 - Vite plugin for Electron main process
|
||||
- vite-plugin-electron-renderer 0.14.6 - Vite plugin for Electron renderer
|
||||
- ESLint 9.39.2 - Code linting (`apps/ui`)
|
||||
- @typescript-eslint/eslint-plugin 8.50.0 - TypeScript ESLint rules
|
||||
- Prettier 3.7.4 - Code formatting (root-level config)
|
||||
- Tailwind CSS 4.1.18 - Utility-first CSS framework
|
||||
- @tailwindcss/vite 4.1.18 - Tailwind Vite integration
|
||||
|
||||
**UI Components & Libraries:**
|
||||
|
||||
- Radix UI - Unstyled accessible component library (@radix-ui packages)
|
||||
- react-dropdown-menu 2.1.16
|
||||
- react-dialog 1.1.15
|
||||
- react-select 2.2.6
|
||||
- react-tooltip 1.2.8
|
||||
- react-tabs 1.1.13
|
||||
- react-collapsible 1.1.12
|
||||
- react-checkbox 1.3.3
|
||||
- react-radio-group 1.3.8
|
||||
- react-popover 1.1.15
|
||||
- react-slider 1.3.6
|
||||
- react-switch 1.2.6
|
||||
- react-scroll-area 1.2.10
|
||||
- react-label 2.1.8
|
||||
- Lucide React 0.562.0 - Icon library
|
||||
- Geist 1.5.1 - Design system UI library
|
||||
- Sonner 2.0.7 - Toast notifications
|
||||
|
||||
**Code Editor & Terminal:**
|
||||
|
||||
- @uiw/react-codemirror 4.25.4 - Code editor React component
|
||||
- CodeMirror (@codemirror packages) 6.x - Editor toolkit
|
||||
- xterm.js (@xterm/xterm) 5.5.0 - Terminal emulator
|
||||
- @xterm/addon-fit 0.10.0 - Fit addon for terminal
|
||||
- @xterm/addon-search 0.15.0 - Search addon for terminal
|
||||
- @xterm/addon-web-links 0.11.0 - Web links addon
|
||||
- @xterm/addon-webgl 0.18.0 - WebGL renderer for terminal
|
||||
|
||||
**Diagram/Graph Visualization:**
|
||||
|
||||
- @xyflow/react 12.10.0 - React flow diagram library
|
||||
- dagre 0.8.5 - Graph layout algorithms
|
||||
|
||||
**Markdown/Content Rendering:**
|
||||
|
||||
- react-markdown 10.1.0 - Markdown parser and renderer
|
||||
- remark-gfm 4.0.1 - GitHub Flavored Markdown support
|
||||
- rehype-raw 7.0.0 - Raw HTML support in markdown
|
||||
- rehype-sanitize 6.0.0 - HTML sanitization
|
||||
|
||||
**Data Validation & Parsing:**
|
||||
|
||||
- zod 3.24.1 or 4.0.0 - Schema validation and TypeScript type inference
|
||||
|
||||
**Utilities:**
|
||||
|
||||
- class-variance-authority 0.7.1 - CSS variant utilities
|
||||
- clsx 2.1.1 - Conditional className utility
|
||||
- cmdk 1.1.1 - Command menu/palette
|
||||
- tailwind-merge 3.4.0 - Tailwind CSS conflict resolution
|
||||
- usehooks-ts 3.1.1 - TypeScript React hooks
|
||||
- @dnd-kit (drag-and-drop) 6.3.1 - Drag and drop library
|
||||
|
||||
**Font Libraries:**
|
||||
|
||||
- @fontsource - Web font packages (Cascadia Code, Fira Code, IBM Plex, Inconsolata, Inter, etc.)
|
||||
|
||||
**Development Utilities:**
|
||||
|
||||
- cross-spawn 7.0.6 - Cross-platform process spawning
|
||||
- dotenv 17.2.3 - Environment variable loading
|
||||
- tsx 4.21.0 - TypeScript execution for Node.js
|
||||
- tree-kill 1.2.2 - Process tree killer utility
|
||||
- node-pty 1.1.0-beta41 - PTY/terminal interface for Node.js
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical - AI/Agent Integration:**
|
||||
|
||||
- @anthropic-ai/claude-agent-sdk 0.1.76 - Core Claude AI provider
|
||||
- @github/copilot-sdk 0.1.16 - GitHub Copilot integration
|
||||
- @openai/codex-sdk 0.77.0 - OpenAI Codex/GPT-4 integration
|
||||
- @modelcontextprotocol/sdk 1.25.2 - Model Context Protocol servers
|
||||
|
||||
**Infrastructure - Internal Packages:**
|
||||
|
||||
- @automaker/types 1.0.0 - Shared TypeScript type definitions
|
||||
- @automaker/utils 1.0.0 - Logging, error handling, utilities
|
||||
- @automaker/platform 1.0.0 - Path management, security, process spawning
|
||||
- @automaker/prompts 1.0.0 - AI prompt templates
|
||||
- @automaker/model-resolver 1.0.0 - Claude model alias resolution
|
||||
- @automaker/dependency-resolver 1.0.0 - Feature dependency ordering
|
||||
- @automaker/git-utils 1.0.0 - Git operations & worktree management
|
||||
- @automaker/spec-parser 1.0.0 - Project specification parsing
|
||||
|
||||
**Server Utilities:**
|
||||
|
||||
- express 5.2.1 - Web framework
|
||||
- cors 2.8.5 - CORS middleware
|
||||
- morgan 1.10.1 - HTTP request logger
|
||||
- cookie-parser 1.4.7 - Cookie parsing middleware
|
||||
- yaml 2.7.0 - YAML parsing and generation
|
||||
|
||||
**Type Definitions:**
|
||||
|
||||
- @types/express 5.0.6
|
||||
- @types/node 22.19.3
|
||||
- @types/react 19.2.7
|
||||
- @types/react-dom 19.2.3
|
||||
- @types/dagre 0.7.53
|
||||
- @types/ws 8.18.1
|
||||
- @types/cookie 0.6.0
|
||||
- @types/cookie-parser 1.4.10
|
||||
- @types/cors 2.8.19
|
||||
- @types/morgan 1.9.10
|
||||
|
||||
**Optional Dependencies (Platform-specific):**
|
||||
|
||||
- lightningcss (various platforms) 1.29.2 - CSS parser (alternate to PostCSS)
|
||||
- dmg-license 1.0.11 - DMG license dialog for macOS
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment:**
|
||||
|
||||
- `.env` and `.env.example` files in `apps/server/` and `apps/ui/`
|
||||
- `dotenv` library loads variables from `.env` files
|
||||
- Key env vars:
|
||||
- `ANTHROPIC_API_KEY` - Claude API authentication
|
||||
- `OPENAI_API_KEY` - OpenAI/Codex authentication
|
||||
- `GITHUB_TOKEN` - GitHub API access
|
||||
- `ANTHROPIC_BASE_URL` - Custom Claude endpoint (optional)
|
||||
- `HOST` - Server bind address (default: 0.0.0.0)
|
||||
- `HOSTNAME` - Hostname for URLs (default: localhost)
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations
|
||||
- `AUTOMAKER_MOCK_AGENT` - Enable mock agent for testing
|
||||
- `AUTOMAKER_AUTO_LOGIN` - Skip login in dev (disabled in production)
|
||||
- `VITE_HOSTNAME` - Frontend API hostname
|
||||
|
||||
**Build:**
|
||||
|
||||
- `apps/ui/electron-builder.config.json` or `apps/ui/package.json` build config
|
||||
- Electron builder targets:
|
||||
- macOS: DMG and ZIP
|
||||
- Windows: NSIS installer
|
||||
- Linux: AppImage, DEB, RPM
|
||||
- Vite config: `apps/ui/vite.config.ts`, `apps/server/tsconfig.json`
|
||||
- TypeScript config: `tsconfig.json` files in each package
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
|
||||
- Node.js 22.x
|
||||
- npm (included with Node.js)
|
||||
- Git (for worktree operations)
|
||||
- Python (optional, for some dev scripts)
|
||||
|
||||
**Production:**
|
||||
|
||||
- Electron desktop app: Windows, macOS, Linux
|
||||
- Web browser: Modern Chromium-based browsers
|
||||
- Server: Any platform supporting Node.js 22.x
|
||||
|
||||
**Deployment Target:**
|
||||
|
||||
- Local desktop (Electron)
|
||||
- Local web server (Express + Vite)
|
||||
- Remote server deployment (Docker, systemd, or other orchestration)
|
||||
|
||||
---
|
||||
|
||||
_Stack analysis: 2026-01-27_
|
||||
340
.planning/codebase/STRUCTURE.md
Normal file
340
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/ # Application packages
|
||||
│ ├── ui/ # React + Electron frontend (port 3007)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── main.ts # Electron/Vite entry point
|
||||
│ │ │ ├── app.tsx # Root React component (splash, router)
|
||||
│ │ │ ├── renderer.tsx # Electron renderer entry
|
||||
│ │ │ ├── routes/ # TanStack Router file-based routes
|
||||
│ │ │ ├── components/ # React components (views, dialogs, UI, layout)
|
||||
│ │ │ ├── store/ # Zustand state management
|
||||
│ │ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── lib/ # Utilities (API client, electron, queries, etc.)
|
||||
│ │ │ ├── electron/ # Electron main & preload process files
|
||||
│ │ │ ├── config/ # UI configuration (fonts, themes, routes)
|
||||
│ │ │ └── styles/ # CSS and theme files
|
||||
│ │ ├── public/ # Static assets
|
||||
│ │ └── tests/ # E2E Playwright tests
|
||||
│ │
|
||||
│ └── server/ # Express backend (port 3008)
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Express app initialization, route mounting
|
||||
│ │ ├── routes/ # REST API endpoints (30+ route folders)
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ ├── providers/ # AI model provider implementations
|
||||
│ │ ├── lib/ # Utilities (events, auth, helpers, etc.)
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ └── types/ # Server-specific type definitions
|
||||
│ └── tests/ # Unit tests (Vitest)
|
||||
│
|
||||
├── libs/ # Shared npm packages (@automaker/*)
|
||||
│ ├── types/ # @automaker/types (no dependencies)
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # Main export with all type definitions
|
||||
│ │ ├── feature.ts # Feature, FeatureStatus, etc.
|
||||
│ │ ├── provider.ts # Provider interfaces, model definitions
|
||||
│ │ ├── settings.ts # Global and project settings types
|
||||
│ │ ├── event.ts # Event types for real-time updates
|
||||
│ │ ├── session.ts # AgentSession, conversation types
|
||||
│ │ ├── model*.ts # Model-specific types (cursor, codex, gemini, etc.)
|
||||
│ │ └── ... 20+ more type files
|
||||
│ │
|
||||
│ ├── utils/ # @automaker/utils (logging, errors, images, context)
|
||||
│ │ └── src/
|
||||
│ │ ├── logger.ts # createLogger() with LogLevel enum
|
||||
│ │ ├── errors.ts # classifyError(), error types
|
||||
│ │ ├── image-utils.ts # Image processing, base64 encoding
|
||||
│ │ ├── context-loader.ts # loadContextFiles() for AI prompts
|
||||
│ │ └── ... more utilities
|
||||
│ │
|
||||
│ ├── platform/ # @automaker/platform (paths, security, OS)
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # Path getters (getFeatureDir, getFeaturesDir, etc.)
|
||||
│ │ ├── secure-fs.ts # Secure filesystem operations
|
||||
│ │ └── config/ # Claude auth detection, allowed paths
|
||||
│ │
|
||||
│ ├── prompts/ # @automaker/prompts (AI prompt templates)
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # Main prompts export
|
||||
│ │ └── *-prompt.ts # Prompt templates for different features
|
||||
│ │
|
||||
│ ├── model-resolver/ # @automaker/model-resolver
|
||||
│ │ └── src/
|
||||
│ │ └── index.ts # resolveModelString() for model aliases
|
||||
│ │
|
||||
│ ├── dependency-resolver/ # @automaker/dependency-resolver
|
||||
│ │ └── src/
|
||||
│ │ └── index.ts # Resolve feature dependencies
|
||||
│ │
|
||||
│ ├── git-utils/ # @automaker/git-utils (git operations)
|
||||
│ │ └── src/
|
||||
│ │ ├── index.ts # getGitRepositoryDiffs(), worktree management
|
||||
│ │ └── ... git helpers
|
||||
│ │
|
||||
│ ├── spec-parser/ # @automaker/spec-parser
|
||||
│ │ └── src/
|
||||
│ │ └── ... spec parsing utilities
|
||||
│ │
|
||||
│ └── tsconfig.base.json # Base TypeScript config for all packages
|
||||
│
|
||||
├── .automaker/ # Project data directory (created by app)
|
||||
│ ├── features/ # Feature storage
|
||||
│ │ └── {featureId}/
|
||||
│ │ ├── feature.json # Feature metadata and content
|
||||
│ │ ├── agent-output.md # Agent execution results
|
||||
│ │ └── images/ # Feature images
|
||||
│ ├── context/ # Context files (CLAUDE.md, etc.)
|
||||
│ ├── settings.json # Per-project settings
|
||||
│ ├── spec.md # Project specification
|
||||
│ └── analysis.json # Project structure analysis
|
||||
│
|
||||
├── data/ # Global data directory (default, configurable)
|
||||
│ ├── settings.json # Global settings, profiles
|
||||
│ ├── credentials.json # Encrypted API keys
|
||||
│ ├── sessions-metadata.json # Chat session metadata
|
||||
│ └── agent-sessions/ # Conversation histories
|
||||
│
|
||||
├── .planning/ # Generated documentation by GSD orchestrator
|
||||
│ └── codebase/ # Codebase analysis documents
|
||||
│ ├── ARCHITECTURE.md # Architecture patterns and layers
|
||||
│ ├── STRUCTURE.md # This file
|
||||
│ ├── STACK.md # Technology stack
|
||||
│ ├── INTEGRATIONS.md # External API integrations
|
||||
│ ├── CONVENTIONS.md # Code style and naming
|
||||
│ ├── TESTING.md # Testing patterns
|
||||
│ └── CONCERNS.md # Technical debt and issues
|
||||
│
|
||||
├── .github/ # GitHub Actions workflows
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── tests/ # Test data and utilities
|
||||
├── docs/ # Documentation
|
||||
├── package.json # Root workspace config
|
||||
├── package-lock.json # Lock file
|
||||
├── CLAUDE.md # Project instructions for Claude Code
|
||||
├── DEVELOPMENT_WORKFLOW.md # Development guidelines
|
||||
└── README.md # Project overview
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**apps/ui/:**
|
||||
|
||||
- Purpose: React frontend for desktop (Electron) and web modes
|
||||
- Build system: Vite 7 with TypeScript
|
||||
- Styling: Tailwind CSS 4
|
||||
- State: Zustand 5 with API persistence
|
||||
- Routing: TanStack Router with file-based structure
|
||||
- Desktop: Electron 39 with preload IPC bridge
|
||||
|
||||
**apps/server/:**
|
||||
|
||||
- Purpose: Express backend API and service layer
|
||||
- Build system: TypeScript → JavaScript
|
||||
- Runtime: Node.js 18+
|
||||
- WebSocket: ws library for real-time streaming
|
||||
- Process management: node-pty for terminal isolation
|
||||
|
||||
**libs/types/:**
|
||||
|
||||
- Purpose: Central type definitions (no dependencies, fast import)
|
||||
- Used by: All other packages and apps
|
||||
- Pattern: Single namespace export from index.ts
|
||||
- Build: Compiled to ESM only
|
||||
|
||||
**libs/utils/:**
|
||||
|
||||
- Purpose: Shared utilities for logging, errors, file operations, image processing
|
||||
- Used by: Server, UI, other libraries
|
||||
- Notable: `createLogger()`, `classifyError()`, `loadContextFiles()`, `readImageAsBase64()`
|
||||
|
||||
**libs/platform/:**
|
||||
|
||||
- Purpose: OS-agnostic path management and security enforcement
|
||||
- Used by: Server services for file operations
|
||||
- Notable: Path normalization, allowed directory enforcement, Claude auth detection
|
||||
|
||||
**libs/prompts/:**
|
||||
|
||||
- Purpose: AI prompt templates injected into agent context
|
||||
- Used by: AgentService when executing features
|
||||
- Pattern: Function exports that return prompt strings
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
|
||||
**Server:**
|
||||
|
||||
- `apps/server/src/index.ts`: Express server initialization, route mounting, WebSocket setup
|
||||
|
||||
**UI (Web):**
|
||||
|
||||
- `apps/ui/src/main.ts`: Vite entry point
|
||||
- `apps/ui/src/app.tsx`: Root React component
|
||||
|
||||
**UI (Electron):**
|
||||
|
||||
- `apps/ui/src/main.ts`: Vite entry point
|
||||
- `apps/ui/src/electron/main-process.ts`: Electron main process
|
||||
- `apps/ui/src/preload.ts`: Electron preload script for IPC bridge
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- `apps/server/src/index.ts`: PORT, HOST, HOSTNAME, DATA_DIR env vars
|
||||
- `apps/ui/src/config/`: Theme options, fonts, model aliases
|
||||
- `libs/types/src/settings.ts`: Settings schema
|
||||
- `.env.local`: Local development overrides (git-ignored)
|
||||
|
||||
**Core Logic:**
|
||||
|
||||
**Server:**
|
||||
|
||||
- `apps/server/src/services/agent-service.ts`: AI agent execution engine (31KB)
|
||||
- `apps/server/src/services/auto-mode-service.ts`: Feature batching and automation (216KB - largest)
|
||||
- `apps/server/src/services/feature-loader.ts`: Feature persistence and loading
|
||||
- `apps/server/src/services/settings-service.ts`: Settings management
|
||||
- `apps/server/src/providers/provider-factory.ts`: AI provider selection
|
||||
|
||||
**UI:**
|
||||
|
||||
- `apps/ui/src/store/app-store.ts`: Global state (84KB - largest frontend file)
|
||||
- `apps/ui/src/lib/http-api-client.ts`: API client with auth (92KB)
|
||||
- `apps/ui/src/components/views/board-view.tsx`: Kanban board (70KB)
|
||||
- `apps/ui/src/routes/__root.tsx`: Root layout with session init (32KB)
|
||||
|
||||
**Testing:**
|
||||
|
||||
**E2E Tests:**
|
||||
|
||||
- `apps/ui/tests/`: Playwright tests organized by feature area
|
||||
- `settings/`, `features/`, `projects/`, `agent/`, `utils/`, `context/`
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
- `libs/*/tests/`: Package-specific Vitest tests
|
||||
- `apps/server/src/tests/`: Server integration tests
|
||||
|
||||
**Test Config:**
|
||||
|
||||
- `vitest.config.ts`: Root Vitest configuration
|
||||
- `apps/ui/playwright.config.ts`: Playwright configuration
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
|
||||
- **Components:** PascalCase.tsx (e.g., `board-view.tsx`, `session-manager.tsx`)
|
||||
- **Services:** camelCase-service.ts (e.g., `agent-service.ts`, `settings-service.ts`)
|
||||
- **Hooks:** use-kebab-case.ts (e.g., `use-auto-mode.ts`, `use-settings-sync.ts`)
|
||||
- **Utilities:** camelCase.ts (e.g., `api-fetch.ts`, `log-parser.ts`)
|
||||
- **Routes:** kebab-case with index.ts pattern (e.g., `routes/agent/index.ts`)
|
||||
- **Tests:** _.test.ts or _.spec.ts (co-located with source)
|
||||
|
||||
**Directories:**
|
||||
|
||||
- **Feature domains:** kebab-case (e.g., `auto-mode/`, `event-history/`, `project-settings-view/`)
|
||||
- **Type categories:** kebab-case plural (e.g., `types/`, `services/`, `providers/`, `routes/`)
|
||||
- **Shared utilities:** kebab-case (e.g., `lib/`, `utils/`, `hooks/`)
|
||||
|
||||
**TypeScript:**
|
||||
|
||||
- **Types:** PascalCase (e.g., `Feature`, `AgentSession`, `ProviderMessage`)
|
||||
- **Interfaces:** PascalCase (e.g., `EventEmitter`, `ProviderFactory`)
|
||||
- **Enums:** PascalCase (e.g., `LogLevel`, `FeatureStatus`)
|
||||
- **Functions:** camelCase (e.g., `createLogger()`, `classifyError()`)
|
||||
- **Constants:** UPPER_SNAKE_CASE (e.g., `DEFAULT_TIMEOUT_MS`, `MAX_RETRIES`)
|
||||
- **Variables:** camelCase (e.g., `featureId`, `settingsService`)
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Feature (end-to-end):**
|
||||
|
||||
- API Route: `apps/server/src/routes/{feature-name}/index.ts`
|
||||
- Service Logic: `apps/server/src/services/{feature-name}-service.ts`
|
||||
- UI Route: `apps/ui/src/routes/{feature-name}.tsx` (simple) or `{feature-name}/` (complex with subdir)
|
||||
- Store: `apps/ui/src/store/{feature-name}-store.ts` (if complex state)
|
||||
- Tests: `apps/ui/tests/{feature-name}/` or `apps/server/src/tests/`
|
||||
|
||||
**New Component/Module:**
|
||||
|
||||
- View Components: `apps/ui/src/components/views/{component-name}/`
|
||||
- Dialog Components: `apps/ui/src/components/dialogs/{dialog-name}.tsx`
|
||||
- Shared Components: `apps/ui/src/components/shared/` or `components/ui/` (shadcn)
|
||||
- Layout Components: `apps/ui/src/components/layout/`
|
||||
|
||||
**Utilities:**
|
||||
|
||||
- New Library: Create in `libs/{package-name}/` with package.json and tsconfig.json
|
||||
- Server Utilities: `apps/server/src/lib/{utility-name}.ts`
|
||||
- Shared Utilities: Extend `libs/utils/src/` or create new lib if self-contained
|
||||
- UI Utilities: `apps/ui/src/lib/{utility-name}.ts`
|
||||
|
||||
**New Provider (AI Model):**
|
||||
|
||||
- Implementation: `apps/server/src/providers/{provider-name}-provider.ts`
|
||||
- Types: Add to `libs/types/src/{provider-name}-models.ts`
|
||||
- Model Resolver: Update `libs/model-resolver/src/index.ts` with model alias mapping
|
||||
- Settings: Update `libs/types/src/settings.ts` for provider-specific config
|
||||
|
||||
## Special Directories
|
||||
|
||||
**apps/ui/electron/:**
|
||||
|
||||
- Purpose: Electron-specific code (main process, IPC handlers, native APIs)
|
||||
- Generated: Yes (preload.ts)
|
||||
- Committed: Yes
|
||||
|
||||
**apps/ui/public/**
|
||||
|
||||
- Purpose: Static assets (sounds, images, icons)
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**apps/ui/dist/:**
|
||||
|
||||
- Purpose: Built web application
|
||||
- Generated: Yes
|
||||
- Committed: No (.gitignore)
|
||||
|
||||
**apps/ui/dist-electron/:**
|
||||
|
||||
- Purpose: Built Electron app bundle
|
||||
- Generated: Yes
|
||||
- Committed: No (.gitignore)
|
||||
|
||||
**.automaker/features/{featureId}/:**
|
||||
|
||||
- Purpose: Per-feature persistent storage
|
||||
- Structure: feature.json, agent-output.md, images/
|
||||
- Generated: Yes (at runtime)
|
||||
- Committed: Yes (tracked in project git)
|
||||
|
||||
**data/:**
|
||||
|
||||
- Purpose: Global data directory (global settings, credentials, sessions)
|
||||
- Generated: Yes (created at first run)
|
||||
- Committed: No (.gitignore)
|
||||
- Configurable: Via DATA_DIR env var
|
||||
|
||||
**node_modules/:**
|
||||
|
||||
- Purpose: Installed dependencies
|
||||
- Generated: Yes
|
||||
- Committed: No (.gitignore)
|
||||
|
||||
**dist/**, **build/:**
|
||||
|
||||
- Purpose: Build artifacts
|
||||
- Generated: Yes
|
||||
- Committed: No (.gitignore)
|
||||
|
||||
---
|
||||
|
||||
_Structure analysis: 2026-01-27_
|
||||
389
.planning/codebase/TESTING.md
Normal file
389
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
|
||||
- Vitest 4.0.16 (for unit and integration tests)
|
||||
- Playwright (for E2E tests)
|
||||
- Config: `apps/server/vitest.config.ts`, `libs/*/vitest.config.ts`, `apps/ui/playwright.config.ts`
|
||||
|
||||
**Assertion Library:**
|
||||
|
||||
- Vitest built-in expect assertions
|
||||
- API: `expect().toBe()`, `expect().toEqual()`, `expect().toHaveLength()`, `expect().toHaveProperty()`
|
||||
|
||||
**Run Commands:**
|
||||
|
||||
```bash
|
||||
npm run test # E2E tests (Playwright, headless)
|
||||
npm run test:headed # E2E tests with browser visible
|
||||
npm run test:packages # All shared package unit tests (vitest)
|
||||
npm run test:server # Server unit tests (vitest run)
|
||||
npm run test:server:coverage # Server tests with coverage report
|
||||
npm run test:all # All tests (packages + server)
|
||||
npm run test:unit # Vitest run (all projects)
|
||||
npm run test:unit:watch # Vitest watch mode
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
|
||||
- Co-located with source: `src/module.ts` has `tests/unit/module.test.ts`
|
||||
- Server tests: `apps/server/tests/` (separate directory)
|
||||
- Library tests: `libs/*/tests/` (each package)
|
||||
- E2E tests: `apps/ui/tests/` (Playwright)
|
||||
|
||||
**Naming:**
|
||||
|
||||
- Pattern: `{moduleName}.test.ts` for unit tests
|
||||
- Pattern: `{moduleName}.spec.ts` for specification tests
|
||||
- Glob pattern: `tests/**/*.test.ts`, `tests/**/*.spec.ts`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```
|
||||
apps/server/
|
||||
├── tests/
|
||||
│ ├── setup.ts # Global test setup
|
||||
│ ├── unit/
|
||||
│ │ ├── providers/ # Provider tests
|
||||
│ │ │ ├── claude-provider.test.ts
|
||||
│ │ │ ├── codex-provider.test.ts
|
||||
│ │ │ └── base-provider.test.ts
|
||||
│ │ └── services/
|
||||
│ └── utils/
|
||||
│ └── helpers.ts # Test utilities
|
||||
└── src/
|
||||
|
||||
libs/platform/
|
||||
├── tests/
|
||||
│ ├── paths.test.ts
|
||||
│ ├── security.test.ts
|
||||
│ ├── subprocess.test.ts
|
||||
│ └── node-finder.test.ts
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { FeatureLoader } from '@/services/feature-loader.js';
|
||||
|
||||
describe('feature-loader.ts', () => {
|
||||
let featureLoader: FeatureLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
featureLoader = new FeatureLoader();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup resources
|
||||
});
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should do specific thing', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Setup pattern: `beforeEach()` initializes test instance, clears mocks
|
||||
- Teardown pattern: `afterEach()` cleans up temp directories, removes created files
|
||||
- Assertion pattern: one logical assertion per test (or multiple closely related)
|
||||
- Test isolation: each test runs with fresh setup
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
|
||||
- Vitest `vi` module: `vi.mock()`, `vi.mocked()`, `vi.clearAllMocks()`
|
||||
- Mock patterns: module mocking, function spying, return value mocking
|
||||
|
||||
**Patterns:**
|
||||
|
||||
Module mocking:
|
||||
|
||||
```typescript
|
||||
vi.mock('@anthropic-ai/claude-agent-sdk');
|
||||
// In test:
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'Response 1' };
|
||||
})()
|
||||
);
|
||||
```
|
||||
|
||||
Async generator mocking (for streaming APIs):
|
||||
|
||||
```typescript
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
```
|
||||
|
||||
Partial mocking with spies:
|
||||
|
||||
```typescript
|
||||
const provider = new TestProvider();
|
||||
const spy = vi.spyOn(provider, 'getName');
|
||||
spy.mockReturnValue('mocked-name');
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
|
||||
- External APIs (Claude SDK, GitHub SDK, cloud services)
|
||||
- File system operations (use temp directories instead when possible)
|
||||
- Network calls
|
||||
- Process execution
|
||||
- Time-dependent operations
|
||||
|
||||
**What NOT to Mock:**
|
||||
|
||||
- Core business logic (test the actual implementation)
|
||||
- Type definitions
|
||||
- Internal module dependencies (test integration with real services)
|
||||
- Standard library functions (fs, path, etc. - use fixtures instead)
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
|
||||
```typescript
|
||||
// Test helper for collecting async generator results
|
||||
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of generator) {
|
||||
results.push(item);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Temporary directory fixture
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
|
||||
projectPath = path.join(tempDir, 'test-project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Location:**
|
||||
|
||||
- Inline in test files for simple fixtures
|
||||
- `tests/utils/helpers.ts` for shared test utilities
|
||||
- Factory functions for complex test objects: `createTestProvider()`, `createMockFeature()`
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements (Server):**
|
||||
|
||||
- Lines: 60%
|
||||
- Functions: 75%
|
||||
- Branches: 55%
|
||||
- Statements: 60%
|
||||
- Config: `apps/server/vitest.config.ts` with thresholds
|
||||
|
||||
**Excluded from Coverage:**
|
||||
|
||||
- Route handlers: tested via integration/E2E tests
|
||||
- Type re-exports
|
||||
- Middleware: tested via integration tests
|
||||
- Prompt templates
|
||||
- MCP integration: awaits MCP SDK integration tests
|
||||
- Provider CLI integrations: awaits integration tests
|
||||
|
||||
**View Coverage:**
|
||||
|
||||
```bash
|
||||
npm run test:server:coverage # Generate coverage report
|
||||
# Opens HTML report in: apps/server/coverage/index.html
|
||||
```
|
||||
|
||||
**Coverage Tools:**
|
||||
|
||||
- Provider: v8
|
||||
- Reporters: text, json, html, lcov
|
||||
- File inclusion: `src/**/*.ts`
|
||||
- File exclusion: `src/**/*.d.ts`, specific service files in thresholds
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
- Scope: Individual functions and methods
|
||||
- Approach: Test inputs → outputs with mocked dependencies
|
||||
- Location: `apps/server/tests/unit/`
|
||||
- Examples:
|
||||
- Provider executeQuery() with mocked SDK
|
||||
- Path construction functions with assertions
|
||||
- Error classification with different error types
|
||||
- Config validation with various inputs
|
||||
|
||||
**Integration Tests:**
|
||||
|
||||
- Scope: Multiple modules working together
|
||||
- Approach: Test actual service calls with real file system or temp directories
|
||||
- Pattern: Setup data → call method → verify results
|
||||
- Example: Feature loader reading/writing feature.json files
|
||||
- Example: Auto-mode service coordinating with multiple services
|
||||
|
||||
**E2E Tests:**
|
||||
|
||||
- Framework: Playwright
|
||||
- Scope: Full user workflows from UI
|
||||
- Location: `apps/ui/tests/`
|
||||
- Config: `apps/ui/playwright.config.ts`
|
||||
- Setup:
|
||||
- Backend server with mock agent enabled
|
||||
- Frontend Vite dev server
|
||||
- Sequential execution (workers: 1) to avoid auth conflicts
|
||||
- Screenshots/traces on failure
|
||||
- Auth: Global setup authentication in `tests/global-setup.ts`
|
||||
- Fixtures: `tests/e2e-fixtures/` for test project data
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Testing:**
|
||||
|
||||
```typescript
|
||||
it('should execute async operation', async () => {
|
||||
const result = await featureLoader.loadFeature(projectPath, featureId);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(featureId);
|
||||
});
|
||||
|
||||
// For streams/generators:
|
||||
const generator = provider.executeQuery({ prompt, model, cwd });
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
expect(results).toHaveLength(2);
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
|
||||
```typescript
|
||||
it('should throw error when feature not found', async () => {
|
||||
await expect(featureLoader.getFeature(projectPath, 'nonexistent')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
// Testing error classification:
|
||||
const errorInfo = classifyError(new Error('ENOENT'));
|
||||
expect(errorInfo.category).toBe('FileSystem');
|
||||
```
|
||||
|
||||
**Fixture Setup:**
|
||||
|
||||
```typescript
|
||||
it('should create feature with images', async () => {
|
||||
// Setup: create temp feature directory
|
||||
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
// Act: perform operation
|
||||
const result = await featureLoader.updateFeature(projectPath, {
|
||||
id: featureId,
|
||||
imagePaths: ['/temp/image.png'],
|
||||
});
|
||||
|
||||
// Assert: verify file operations
|
||||
const migratedPath = path.join(featureDir, 'images', 'image.png');
|
||||
expect(fs.existsSync(migratedPath)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
**Mock Reset Pattern:**
|
||||
|
||||
```typescript
|
||||
// In vitest.config.ts:
|
||||
mockReset: true, // Reset all mocks before each test
|
||||
restoreMocks: true, // Restore original implementations
|
||||
clearMocks: true, // Clear mock call history
|
||||
|
||||
// In test:
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
**Vitest Config Patterns:**
|
||||
|
||||
Server config (`apps/server/vitest.config.ts`):
|
||||
|
||||
- Environment: node
|
||||
- Globals: true (describe/it without imports)
|
||||
- Setup files: `./tests/setup.ts`
|
||||
- Alias resolution: resolves `@automaker/*` to source files for mocking
|
||||
|
||||
Library config:
|
||||
|
||||
- Simpler setup: just environment and globals
|
||||
- Coverage with high thresholds (90%+ lines)
|
||||
|
||||
**Global Setup:**
|
||||
|
||||
```typescript
|
||||
// tests/setup.ts
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DATA_DIR = '/tmp/test-data';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
**Isolation:**
|
||||
|
||||
- Each test is independent (no state sharing)
|
||||
- Cleanup temp files in afterEach
|
||||
- Reset mocks and environment variables in beforeEach
|
||||
|
||||
**Clarity:**
|
||||
|
||||
- Descriptive test names: "should do X when Y condition"
|
||||
- One logical assertion per test
|
||||
- Clear arrange-act-assert structure
|
||||
|
||||
**Speed:**
|
||||
|
||||
- Mock external services
|
||||
- Use in-memory temp directories
|
||||
- Avoid real network calls
|
||||
- Sequential E2E tests to prevent conflicts
|
||||
|
||||
**Maintainability:**
|
||||
|
||||
- Use beforeEach/afterEach for common setup
|
||||
- Extract test helpers to `tests/utils/`
|
||||
- Keep test data simple and local
|
||||
- Mock consistently across tests
|
||||
|
||||
---
|
||||
|
||||
_Testing analysis: 2026-01-27_
|
||||
@@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
- `opus` → `claude-opus-4-6`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
# Development Workflow
|
||||
|
||||
This document defines the standard workflow for keeping a branch in sync with the upstream
|
||||
release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`.
|
||||
|
||||
## Quick Decision Rule
|
||||
|
||||
1. Ask the user to select a workflow:
|
||||
- **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements
|
||||
and will push the same fixes to both origin and upstream RC when you have local
|
||||
commits to publish.
|
||||
- **PR Workflow** → you are starting new feature work on a new branch; upstream updates
|
||||
happen via PR only.
|
||||
2. After the user selects, run:
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
3. Use the status output to confirm alignment. If it reports **diverged**, default to
|
||||
merging `upstream/<TARGET_RC>` into the current branch and preserving local commits.
|
||||
For Sync Workflow, when the working tree is clean and you are behind upstream RC,
|
||||
proceed with the fetch + merge without asking for additional confirmation.
|
||||
|
||||
## Target RC Resolution
|
||||
|
||||
The target RC is resolved dynamically so the workflow stays current as the RC changes.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. Latest `upstream/v*rc` branch (auto-detected)
|
||||
2. `upstream/HEAD` (fallback)
|
||||
3. If neither is available, you must pass `--rc <branch>`
|
||||
|
||||
Override for a single run:
|
||||
|
||||
```bash
|
||||
./check-sync.sh --rc <rc-branch>
|
||||
```
|
||||
|
||||
## Pre-Flight Checklist
|
||||
|
||||
1. Confirm a clean working tree:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Confirm the current branch:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
3. Ensure remotes exist (origin + upstream):
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
## Sync Workflow (Upstream Sync)
|
||||
|
||||
Use this flow when you are updating the current branch with fixes or improvements and
|
||||
intend to keep origin and upstream RC in lockstep.
|
||||
|
||||
1. **Check sync status**
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
2. **Update from upstream RC before editing (no pulls)**
|
||||
- **Behind upstream RC** → fetch and merge RC into your branch:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git merge upstream/<TARGET_RC> --no-edit
|
||||
```
|
||||
When the working tree is clean and the user selected Sync Workflow, proceed without
|
||||
an extra confirmation prompt.
|
||||
- **Diverged** → stop and resolve manually.
|
||||
3. **Resolve conflicts if needed**
|
||||
- Handle conflicts intelligently: preserve upstream behavior and your local intent.
|
||||
4. **Make changes and commit (if you are delivering fixes)**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "type: description"
|
||||
```
|
||||
5. **Build to verify**
|
||||
```bash
|
||||
npm run build:packages
|
||||
npm run build
|
||||
```
|
||||
6. **Push after a successful merge to keep remotes aligned**
|
||||
- If you only merged upstream RC changes, push **origin only** to sync your fork:
|
||||
```bash
|
||||
git push origin <branch>
|
||||
```
|
||||
- If you have local fixes to publish, push **origin + upstream**:
|
||||
```bash
|
||||
git push origin <branch>
|
||||
git push upstream <branch>:<TARGET_RC>
|
||||
```
|
||||
- Always ask the user which push to perform.
|
||||
- Origin (origin-only sync):
|
||||
```bash
|
||||
git push origin <branch>
|
||||
```
|
||||
- Upstream RC (publish the same fixes when you have local commits):
|
||||
```bash
|
||||
git push upstream <branch>:<TARGET_RC>
|
||||
```
|
||||
7. **Re-check sync**
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
|
||||
## PR Workflow (Feature Work)
|
||||
|
||||
Use this flow only for new feature work on a new branch. Do not push to upstream RC.
|
||||
|
||||
1. **Create or switch to a feature branch**
|
||||
```bash
|
||||
git checkout -b <branch>
|
||||
```
|
||||
2. **Make changes and commit**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "type: description"
|
||||
```
|
||||
3. **Merge upstream RC before shipping**
|
||||
```bash
|
||||
git merge upstream/<TARGET_RC> --no-edit
|
||||
```
|
||||
4. **Build and/or test**
|
||||
```bash
|
||||
npm run build:packages
|
||||
npm run build
|
||||
```
|
||||
5. **Push to origin**
|
||||
```bash
|
||||
git push -u origin <branch>
|
||||
```
|
||||
6. **Create or update the PR**
|
||||
- Use `gh pr create` or the GitHub UI.
|
||||
7. **Review and follow-up**
|
||||
|
||||
- Apply feedback, commit changes, and push again.
|
||||
- Re-run `./check-sync.sh` if additional upstream sync is needed.
|
||||
|
||||
## Conflict Resolution Checklist
|
||||
|
||||
1. Identify which changes are from upstream vs. local.
|
||||
2. Preserve both behaviors where possible; avoid dropping either side.
|
||||
3. Prefer minimal, safe integrations over refactors.
|
||||
4. Re-run build commands after resolving conflicts.
|
||||
5. Re-run `./check-sync.sh` to confirm status.
|
||||
|
||||
## Build/Test Matrix
|
||||
|
||||
- **Sync Workflow**: `npm run build:packages` and `npm run build`.
|
||||
- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests).
|
||||
|
||||
## Post-Sync Verification
|
||||
|
||||
1. `git status` should be clean.
|
||||
2. `./check-sync.sh` should show expected alignment.
|
||||
3. Verify recent commits with:
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
## check-sync.sh Usage
|
||||
|
||||
- Uses dynamic Target RC resolution (see above).
|
||||
- Override target RC:
|
||||
```bash
|
||||
./check-sync.sh --rc <rc-branch>
|
||||
```
|
||||
- Optional preview limit:
|
||||
```bash
|
||||
./check-sync.sh --preview 10
|
||||
```
|
||||
- The script prints sync status for both origin and upstream and previews recent commits
|
||||
when you are behind.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop and ask for guidance if any of the following are true:
|
||||
|
||||
- The working tree is dirty and you are about to merge or push.
|
||||
- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed.
|
||||
- The script cannot resolve a target RC and requests `--rc`.
|
||||
- A build fails after sync or conflict resolution.
|
||||
|
||||
## AI Agent Guardrails
|
||||
|
||||
- Always run `./check-sync.sh` before merges or pushes.
|
||||
- Always ask for explicit user approval before any push command.
|
||||
- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the
|
||||
working tree is clean and the user has already selected the Sync Workflow.
|
||||
- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not
|
||||
on the script's workflow hint.
|
||||
- Only use force push when the user explicitly requests a history rewrite.
|
||||
- Ask for explicit approval before dependency installs, branch deletion, or destructive operations.
|
||||
- When resolving merge conflicts, preserve both upstream changes and local intent where possible.
|
||||
- Do not create or switch to new branches unless the user explicitly requests it.
|
||||
|
||||
## AI Agent Decision Guidance
|
||||
|
||||
Agents should provide concrete, task-specific suggestions instead of repeatedly asking
|
||||
open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to
|
||||
propose a default path plus one or two alternatives, and only ask for confirmation when
|
||||
an action requires explicit approval.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- If the intent is RC maintenance, recommend the Sync Workflow and proceed with
|
||||
safe preparation steps (status checks, previews). If the branch is behind upstream RC,
|
||||
fetch and merge without additional confirmation when the working tree is clean, then
|
||||
push to origin to keep the fork aligned. Push upstream only when there are local fixes
|
||||
to publish.
|
||||
- If the intent is new feature work, recommend the PR Workflow and proceed with safe
|
||||
preparation steps (status checks, identifying scope). Ask for approval before merges,
|
||||
pushes, or dependency installs.
|
||||
- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge
|
||||
`upstream/<TARGET_RC>` into the current branch and preserve local commits.
|
||||
- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance
|
||||
with a short explanation of the divergence and the minimal options to resolve it.
|
||||
If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the
|
||||
script hint. When the intent is new feature work, use the PR Workflow and avoid upstream
|
||||
RC pushes.
|
||||
|
||||
Suggestion format (keep it short):
|
||||
|
||||
- **Recommended**: one sentence with the default path and why it fits the task.
|
||||
- **Alternatives**: one or two options with the tradeoff or prerequisite.
|
||||
- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync
|
||||
workflow pushes and merges).
|
||||
|
||||
## Failure Modes and How to Avoid Them
|
||||
|
||||
Sync Workflow:
|
||||
|
||||
- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging.
|
||||
- Diverged from upstream RC: stop and resolve manually before any merge or push.
|
||||
- Dirty working tree: commit or stash before syncing to avoid accidental merges.
|
||||
- Missing remotes: ensure both `origin` and `upstream` are configured before syncing.
|
||||
- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing.
|
||||
|
||||
PR Workflow:
|
||||
|
||||
- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping.
|
||||
- Pushing the wrong branch: confirm `git branch --show-current` before pushing.
|
||||
- Unreviewed changes: always commit and push to origin before opening or updating a PR.
|
||||
- Skipped tests/builds: run the build commands before declaring the PR ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- Avoid merging with uncommitted changes; commit or stash first.
|
||||
- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push,
|
||||
which should only be done with an explicit user request.
|
||||
- Use clear, conventional commit messages and split unrelated changes into separate commits.
|
||||
17
Dockerfile
17
Dockerfile
@@ -118,6 +118,7 @@ RUN curl -fsSL https://opencode.ai/install | bash && \
|
||||
echo "=== Checking OpenCode CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
|
||||
|
||||
USER root
|
||||
|
||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||
@@ -147,6 +148,15 @@ 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
|
||||
|
||||
# Install Playwright Chromium browser for AI agent verification tests
|
||||
# This adds ~300MB to the image but enables automated testing mode out of the box
|
||||
# Using the locally installed playwright ensures we use the pinned version from package-lock.json
|
||||
USER automaker
|
||||
RUN ./node_modules/.bin/playwright install chromium && \
|
||||
echo "=== Playwright Chromium installed ===" && \
|
||||
ls -la /home/automaker/.cache/ms-playwright/
|
||||
USER root
|
||||
|
||||
# Create data and projects directories
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
@@ -199,9 +209,10 @@ 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
|
||||
# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
|
||||
# to the server container. This avoids CORS issues entirely in Docker Compose setups.
|
||||
# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||
|
||||
158
LICENSE
158
LICENSE
@@ -1,141 +1,27 @@
|
||||
AUTOMAKER LICENSE AGREEMENT
|
||||
## Project Status
|
||||
|
||||
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
|
||||
|
||||
1. DEFINITIONS
|
||||
|
||||
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
|
||||
|
||||
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
|
||||
|
||||
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
|
||||
|
||||
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
|
||||
|
||||
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
|
||||
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
|
||||
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
|
||||
|
||||
Monetization does NOT include:
|
||||
|
||||
- Using the Software internally within your organization, regardless of whether your organization is for-profit
|
||||
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
|
||||
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
|
||||
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
|
||||
|
||||
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
|
||||
|
||||
- Cody Seibert (webdevcody)
|
||||
- SuperComboGamer (SCG)
|
||||
- Kacper Lachowicz (Shironex, Shirone)
|
||||
- Ben Scott (trueheads)
|
||||
|
||||
2. GRANT OF LICENSE
|
||||
|
||||
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
|
||||
|
||||
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
|
||||
|
||||
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
|
||||
|
||||
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
|
||||
|
||||
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
|
||||
|
||||
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
|
||||
|
||||
3. RESTRICTIONS
|
||||
|
||||
Licensee may NOT:
|
||||
|
||||
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
|
||||
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
|
||||
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
|
||||
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
|
||||
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
|
||||
- Remove or alter any copyright notices or license terms
|
||||
- Use the Software in any manner that violates applicable laws or regulations
|
||||
|
||||
Licensee MAY:
|
||||
|
||||
- Use the Software internally within their organization (commercial or non-profit)
|
||||
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
|
||||
- Modify the Software for internal use within their organization (commercial or non-profit)
|
||||
|
||||
4. CORE CONTRIBUTOR STATUS MANAGEMENT
|
||||
|
||||
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
|
||||
|
||||
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
|
||||
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
|
||||
- Discussing the Software through project communication channels
|
||||
- Committing code changes to the project repository
|
||||
- Submitting bug fixes or patches
|
||||
- Participating in project-related discussions or decision-making
|
||||
|
||||
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
|
||||
|
||||
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
|
||||
|
||||
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
|
||||
|
||||
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||
|
||||
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
|
||||
|
||||
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
|
||||
|
||||
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
|
||||
|
||||
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
|
||||
|
||||
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
|
||||
|
||||
6. TERMINATION
|
||||
|
||||
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
|
||||
|
||||
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||
|
||||
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||
|
||||
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
|
||||
|
||||
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
||||
|
||||
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
|
||||
|
||||
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
|
||||
- DATA LOSS OR CORRUPTION
|
||||
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
|
||||
- FINANCIAL LOSSES
|
||||
- BUSINESS INTERRUPTION
|
||||
|
||||
8. LICENSE AMENDMENTS
|
||||
|
||||
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
|
||||
|
||||
9. CONTACT
|
||||
|
||||
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
|
||||
|
||||
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
|
||||
- Website: https://automaker.app
|
||||
- Email: automakerapp@gmail.com
|
||||
|
||||
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
|
||||
|
||||
10. GOVERNING LAW
|
||||
|
||||
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
|
||||
|
||||
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Automaker Core Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
2
OPENCODE_CONFIG_CONTENT
Normal file
2
OPENCODE_CONFIG_CONTENT
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",}
|
||||
87
README.md
87
README.md
@@ -288,6 +288,31 @@ services:
|
||||
|
||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||
|
||||
> **⚠️ Important: Linux/WSL Users**
|
||||
>
|
||||
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
|
||||
>
|
||||
> ```bash
|
||||
> # Check your UID/GID
|
||||
> id -u # outputs your UID (e.g., 1000)
|
||||
> id -g # outputs your GID (e.g., 1000)
|
||||
> ```
|
||||
>
|
||||
> Create a `.env` file in the automaker directory:
|
||||
>
|
||||
> ```
|
||||
> UID=1000
|
||||
> GID=1000
|
||||
> ```
|
||||
>
|
||||
> Then rebuild the images:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose build
|
||||
> ```
|
||||
>
|
||||
> Without this, files written by the container will be inaccessible to your host user.
|
||||
|
||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||
|
||||
To enable git push and GitHub CLI operations inside the container:
|
||||
@@ -338,6 +363,42 @@ services:
|
||||
|
||||
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
|
||||
|
||||
##### Playwright for Automated Testing
|
||||
|
||||
The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
|
||||
|
||||
**No additional setup required** - Playwright verification works out of the box.
|
||||
|
||||
#### Optional: Persist browsers for manual updates
|
||||
|
||||
By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
|
||||
|
||||
**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
|
||||
|
||||
```bash
|
||||
# After adding the volume mount for the first time
|
||||
docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
```
|
||||
|
||||
Add this to your `docker-compose.override.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
- playwright-cache:/home/automaker/.cache/ms-playwright
|
||||
|
||||
volumes:
|
||||
playwright-cache:
|
||||
name: automaker-playwright-cache
|
||||
```
|
||||
|
||||
**Updating browsers manually:**
|
||||
|
||||
```bash
|
||||
docker exec --user automaker -w /app automaker-server npx playwright install chromium
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
@@ -644,26 +705,10 @@ Join the **Agentic Jumpstart** Discord to connect with other builders exploring
|
||||
|
||||
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
|
||||
## Project Status
|
||||
|
||||
**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
|
||||
|
||||
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.
|
||||
This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
@@ -52,6 +52,12 @@ HOST=0.0.0.0
|
||||
# Port to run the server on
|
||||
PORT=3008
|
||||
|
||||
# Port to run the server on for testing
|
||||
TEST_SERVER_PORT=3108
|
||||
|
||||
# Port to run the UI on for testing
|
||||
TEST_PORT=3107
|
||||
|
||||
# Data directory for sessions and metadata
|
||||
DATA_DIR=./data
|
||||
|
||||
|
||||
74
apps/server/eslint.config.mjs
Normal file
74
apps/server/eslint.config.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import js from '@eslint/js';
|
||||
import ts from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
// Node.js globals
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
fetch: 'readonly',
|
||||
Response: 'readonly',
|
||||
Request: 'readonly',
|
||||
Headers: 'readonly',
|
||||
FormData: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
// Timers
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setImmediate: 'readonly',
|
||||
clearImmediate: 'readonly',
|
||||
queueMicrotask: 'readonly',
|
||||
// Node.js types
|
||||
NodeJS: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': ts,
|
||||
},
|
||||
rules: {
|
||||
...ts.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// Server code frequently works with terminal output containing ANSI escape codes
|
||||
'no-control-regex': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-nocheck': 'allow-with-description',
|
||||
minimumDescriptionLength: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores(['dist/**', 'node_modules/**']),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -24,7 +24,7 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.32",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
@@ -45,6 +45,7 @@
|
||||
"yaml": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/cookie": "0.6.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
import { createSettingsRoutes } from './routes/settings/index.js';
|
||||
import { AgentService } from './services/agent-service.js';
|
||||
import { FeatureLoader } from './services/feature-loader.js';
|
||||
import { AutoModeService } from './services/auto-mode-service.js';
|
||||
import { AutoModeServiceCompat } from './services/auto-mode/index.js';
|
||||
import { getTerminalService } from './services/terminal-service.js';
|
||||
import { SettingsService } from './services/settings-service.js';
|
||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||
@@ -66,6 +66,10 @@ import { createCodexRoutes } from './routes/codex/index.js';
|
||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||
import { createZaiRoutes } from './routes/zai/index.js';
|
||||
import { ZaiUsageService } from './services/zai-usage-service.js';
|
||||
import { createGeminiRoutes } from './routes/gemini/index.js';
|
||||
import { GeminiUsageService } from './services/gemini-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';
|
||||
@@ -121,21 +125,57 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
|
||||
(async () => {
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
|
||||
logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
|
||||
hasAnthropicKey,
|
||||
hasEnvOAuthToken,
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasEnvOAuthToken) {
|
||||
logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Claude Code CLI authentication
|
||||
// Store indicators outside the try block so we can use them in the warning message
|
||||
let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null;
|
||||
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
cliAuthIndicators = await getClaudeAuthIndicators();
|
||||
const indicators = cliAuthIndicators;
|
||||
|
||||
// Log detailed credential detection results
|
||||
const { checks, ...indicatorSummary } = indicators;
|
||||
logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary);
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] File check details:', checks);
|
||||
|
||||
const hasCliAuth =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
(indicators.hasCredentialsFile &&
|
||||
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
|
||||
|
||||
logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
|
||||
hasCliAuth,
|
||||
reason: hasCliAuth
|
||||
? indicators.hasStatsCacheWithActivity
|
||||
? 'stats cache with activity'
|
||||
: indicators.hasSettingsFile && indicators.hasProjectsSessions
|
||||
? 'settings file + project sessions'
|
||||
: indicators.credentials?.hasOAuthToken
|
||||
? 'credentials file with OAuth token'
|
||||
: 'credentials file with API key'
|
||||
: 'no valid credentials found',
|
||||
});
|
||||
|
||||
if (hasCliAuth) {
|
||||
logger.info('✓ Claude Code CLI authentication detected');
|
||||
return;
|
||||
@@ -145,7 +185,7 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
logger.warn('Error checking for Claude Code CLI authentication:', error);
|
||||
}
|
||||
|
||||
// No authentication found - show warning
|
||||
// No authentication found - show warning with paths that were checked
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
@@ -158,6 +198,33 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
// Build paths checked summary from the indicators (if available)
|
||||
let pathsCheckedInfo = '';
|
||||
if (cliAuthIndicators) {
|
||||
const pathsChecked: string[] = [];
|
||||
|
||||
// Collect paths that were checked (paths are always populated strings)
|
||||
pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
|
||||
pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
|
||||
pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
|
||||
for (const credFile of cliAuthIndicators.checks.credentialFiles) {
|
||||
pathsChecked.push(`Credentials: ${credFile.path}`);
|
||||
}
|
||||
|
||||
if (pathsChecked.length > 0) {
|
||||
pathsCheckedInfo = `
|
||||
║ ║
|
||||
║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║
|
||||
${pathsChecked
|
||||
.map((p) => {
|
||||
const maxLen = BOX_CONTENT_WIDTH - 4;
|
||||
const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p;
|
||||
return `║ ${display.padEnd(maxLen)} ║`;
|
||||
})
|
||||
.join('\n')}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${wHeader}║
|
||||
@@ -169,7 +236,7 @@ const BOX_CONTENT_WIDTH = 67;
|
||||
║ ${w3}║
|
||||
║ ${w4}║
|
||||
║ ${w5}║
|
||||
║ ${w6}║
|
||||
║ ${w6}║${pathsCheckedInfo}
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
@@ -200,6 +267,26 @@ app.use(
|
||||
// CORS configuration
|
||||
// When using credentials (cookies), origin cannot be '*'
|
||||
// We dynamically allow the requesting origin for local development
|
||||
|
||||
// Check if origin is a local/private network address
|
||||
function isLocalOrigin(origin: string): boolean {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const hostname = url.hostname;
|
||||
return (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
@@ -210,35 +297,25 @@ app.use(
|
||||
}
|
||||
|
||||
// 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'));
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (allowedOrigins && allowedOrigins.length > 0) {
|
||||
if (allowedOrigins.includes('*')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow all localhost/loopback origins (any port)
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const hostname = url.hostname;
|
||||
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.')
|
||||
) {
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore URL parsing errors
|
||||
// Fall through to local network check below
|
||||
}
|
||||
|
||||
// Allow all localhost/loopback/private network origins (any port)
|
||||
if (isLocalOrigin(origin)) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
@@ -258,11 +335,15 @@ const events: EventEmitter = createEventEmitter();
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
|
||||
// Auto-mode services: compatibility layer provides old interface while using new architecture
|
||||
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const codexAppServerService = new CodexAppServerService();
|
||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const zaiUsageService = new ZaiUsageService();
|
||||
const geminiUsageService = new GeminiUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
@@ -303,24 +384,77 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
||||
logger.warn('Failed to check for legacy settings migration:', err);
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
// Fetch global settings once and reuse for logging config and feature reconciliation
|
||||
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
|
||||
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
|
||||
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
|
||||
globalSettings = await settingsService.getGlobalSettings();
|
||||
} catch {
|
||||
logger.warn('Failed to load global settings, using defaults');
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
if (globalSettings) {
|
||||
try {
|
||||
if (
|
||||
globalSettings.serverLogLevel &&
|
||||
LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined
|
||||
) {
|
||||
setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]);
|
||||
logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`);
|
||||
}
|
||||
// Apply request logging setting (default true if not set)
|
||||
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
|
||||
setRequestLoggingEnabled(enableRequestLog);
|
||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||
} catch {
|
||||
logger.warn('Failed to apply logging settings, using defaults');
|
||||
}
|
||||
// Apply request logging setting (default true if not set)
|
||||
const enableRequestLog = settings.enableRequestLogging ?? true;
|
||||
setRequestLoggingEnabled(enableRequestLog);
|
||||
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load logging settings, using defaults');
|
||||
}
|
||||
|
||||
await agentService.initialize();
|
||||
logger.info('Agent service initialized');
|
||||
|
||||
// Reconcile feature states on startup
|
||||
// After any type of restart (clean, forced, crash), features may be stuck in
|
||||
// transient states (in_progress, interrupted, pipeline_*) that don't match reality.
|
||||
// Reconcile them back to resting states before the UI is served.
|
||||
if (globalSettings) {
|
||||
try {
|
||||
if (globalSettings.projects && globalSettings.projects.length > 0) {
|
||||
let totalReconciled = 0;
|
||||
for (const project of globalSettings.projects) {
|
||||
const count = await autoModeService.reconcileFeatureStates(project.path);
|
||||
totalReconciled += count;
|
||||
}
|
||||
if (totalReconciled > 0) {
|
||||
logger.info(
|
||||
`[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)`
|
||||
);
|
||||
} else {
|
||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||
}
|
||||
|
||||
// Resume interrupted features in the background after reconciliation.
|
||||
// This uses the saved execution state to identify features that were running
|
||||
// before the restart (their statuses have been reset to ready/backlog by
|
||||
// reconciliation above). Running in background so it doesn't block startup.
|
||||
if (totalReconciled > 0) {
|
||||
for (const project of globalSettings.projects) {
|
||||
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||
logger.warn(
|
||||
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap Codex model cache in background (don't block server startup)
|
||||
void codexModelCacheService.getModels().catch((err) => {
|
||||
logger.error('Failed to bootstrap Codex model cache:', err);
|
||||
@@ -371,6 +505,8 @@ app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
|
||||
app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
@@ -390,7 +526,7 @@ const server = createServer(app);
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
@@ -473,7 +609,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
logger.info('Sending event to client:', {
|
||||
type,
|
||||
messageLength: message.length,
|
||||
sessionId: (payload as any)?.sessionId,
|
||||
sessionId: (payload as Record<string, unknown>)?.sessionId,
|
||||
});
|
||||
ws.send(message);
|
||||
} else {
|
||||
@@ -539,8 +675,15 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
|
||||
// Check if session exists
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
logger.info(`Session ${sessionId} not found`);
|
||||
ws.close(4004, 'Session not found');
|
||||
logger.warn(
|
||||
`Terminal session ${sessionId} not found. ` +
|
||||
`The session may have exited, been deleted, or was never created. ` +
|
||||
`Active terminal sessions: ${terminalService.getSessionCount()}`
|
||||
);
|
||||
ws.close(
|
||||
4004,
|
||||
'Session not found. The terminal session may have expired or been closed. Please create a new terminal.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CliDetection');
|
||||
|
||||
export interface CliInfo {
|
||||
name: string;
|
||||
@@ -86,7 +83,7 @@ export async function detectCli(
|
||||
options: CliDetectionOptions = {}
|
||||
): Promise<CliDetectionResult> {
|
||||
const config = CLI_CONFIGS[provider];
|
||||
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
|
||||
const { timeout = 5000 } = options;
|
||||
const issues: string[] = [];
|
||||
|
||||
const cliInfo: CliInfo = {
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ErrorClassification {
|
||||
suggestedAction?: string;
|
||||
retryable: boolean;
|
||||
provider?: string;
|
||||
context?: Record<string, any>;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ErrorPattern {
|
||||
@@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
||||
export function classifyError(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
context?: Record<string, any>
|
||||
context?: Record<string, unknown>
|
||||
): ErrorClassification {
|
||||
const errorText = getErrorText(error);
|
||||
|
||||
@@ -281,18 +281,19 @@ function getErrorText(error: unknown): string {
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
// Handle structured error objects
|
||||
const errorObj = error as any;
|
||||
const errorObj = error as Record<string, unknown>;
|
||||
|
||||
if (errorObj.message) {
|
||||
if (typeof errorObj.message === 'string') {
|
||||
return errorObj.message;
|
||||
}
|
||||
|
||||
if (errorObj.error?.message) {
|
||||
return errorObj.error.message;
|
||||
const nestedError = errorObj.error;
|
||||
if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
|
||||
return String((nestedError as Record<string, unknown>).message);
|
||||
}
|
||||
|
||||
if (errorObj.error) {
|
||||
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
|
||||
if (nestedError) {
|
||||
return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
@@ -307,7 +308,7 @@ function getErrorText(error: unknown): string {
|
||||
export function createErrorResponse(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
context?: Record<string, any>
|
||||
context?: Record<string, unknown>
|
||||
): {
|
||||
success: false;
|
||||
error: string;
|
||||
@@ -335,7 +336,7 @@ export function logError(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
operation?: string,
|
||||
additionalContext?: Record<string, any>
|
||||
additionalContext?: Record<string, unknown>
|
||||
): void {
|
||||
const classification = classifyError(error, provider, {
|
||||
operation,
|
||||
|
||||
37
apps/server/src/lib/exec-utils.ts
Normal file
37
apps/server/src/lib/exec-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared execution utilities
|
||||
*
|
||||
* Common helpers for spawning child processes with the correct environment.
|
||||
* Used by both route handlers and service layers.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ExecUtils');
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
export const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
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(`${context}:`, error);
|
||||
}
|
||||
62
apps/server/src/lib/git-log-parser.ts
Normal file
62
apps/server/src/lib/git-log-parser.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface CommitFields {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function parseGitLogOutput(output: string): CommitFields[] {
|
||||
const commits: CommitFields[] = [];
|
||||
|
||||
// Split by NUL character to separate commits
|
||||
const commitBlocks = output.split('\0').filter((block) => block.trim());
|
||||
|
||||
for (const block of commitBlocks) {
|
||||
const allLines = block.split('\n');
|
||||
|
||||
// Skip leading empty lines that may appear at block boundaries
|
||||
let startIndex = 0;
|
||||
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
|
||||
startIndex++;
|
||||
}
|
||||
const fields = allLines.slice(startIndex);
|
||||
|
||||
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
|
||||
if (fields.length < 6) {
|
||||
continue; // Skip malformed blocks
|
||||
}
|
||||
|
||||
const commit: CommitFields = {
|
||||
hash: fields[0].trim(),
|
||||
shortHash: fields[1].trim(),
|
||||
author: fields[2].trim(),
|
||||
authorEmail: fields[3].trim(),
|
||||
date: fields[4].trim(),
|
||||
subject: fields[5].trim(),
|
||||
body: fields.slice(6).join('\n').trim(),
|
||||
};
|
||||
|
||||
commits.push(commit);
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a commit object from parsed fields, matching the expected API response format
|
||||
*/
|
||||
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
|
||||
return {
|
||||
hash: fields.hash,
|
||||
shortHash: fields.shortHash,
|
||||
author: fields.author,
|
||||
authorEmail: fields.authorEmail,
|
||||
date: fields.date,
|
||||
subject: fields.subject,
|
||||
body: fields.body,
|
||||
files: files || [],
|
||||
};
|
||||
}
|
||||
208
apps/server/src/lib/git.ts
Normal file
208
apps/server/src/lib/git.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Shared git command execution utilities.
|
||||
*
|
||||
* This module provides the canonical `execGitCommand` helper and common
|
||||
* git utilities used across services and routes. All consumers should
|
||||
* import from here rather than defining their own copy.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { spawnProcess } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitLib');
|
||||
|
||||
// ============================================================================
|
||||
// Secure Command Execution
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute git command with array arguments to prevent command injection.
|
||||
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
||||
*
|
||||
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
||||
* @param cwd - Working directory to execute the command in
|
||||
* @param env - Optional additional environment variables to pass to the git process.
|
||||
* These are merged on top of the current process environment. Pass
|
||||
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
|
||||
* system locale so that text-based output parsing remains reliable.
|
||||
* @param abortController - Optional AbortController to cancel the git process.
|
||||
* When the controller is aborted the underlying process is sent SIGTERM and
|
||||
* the returned promise rejects with an Error whose message is 'Process aborted'.
|
||||
* @returns Promise resolving to stdout output
|
||||
* @throws Error with stderr/stdout message if command fails. The thrown error
|
||||
* also has `stdout` and `stderr` string properties for structured access.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Safe: no injection possible
|
||||
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||
*
|
||||
* // Force English output for reliable text parsing:
|
||||
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
|
||||
*
|
||||
* // With a process-level timeout:
|
||||
* const controller = new AbortController();
|
||||
* const timerId = setTimeout(() => controller.abort(), 30_000);
|
||||
* try {
|
||||
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||
* } finally {
|
||||
* clearTimeout(timerId);
|
||||
* }
|
||||
*
|
||||
* // Instead of unsafe:
|
||||
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
||||
* ```
|
||||
*/
|
||||
export async function execGitCommand(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env?: Record<string, string>,
|
||||
abortController?: AbortController
|
||||
): Promise<string> {
|
||||
const result = await spawnProcess({
|
||||
command: 'git',
|
||||
args,
|
||||
cwd,
|
||||
...(env !== undefined ? { env } : {}),
|
||||
...(abortController !== undefined ? { abortController } : {}),
|
||||
});
|
||||
|
||||
// spawnProcess returns { stdout, stderr, exitCode }
|
||||
if (result.exitCode === 0) {
|
||||
return result.stdout;
|
||||
} else {
|
||||
const errorMessage =
|
||||
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
|
||||
throw Object.assign(new Error(errorMessage), {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Common Git Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the current branch name for the given worktree.
|
||||
*
|
||||
* This is the canonical implementation shared across services. Services
|
||||
* should import this rather than duplicating the logic locally.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @returns The current branch name (trimmed)
|
||||
*/
|
||||
export async function getCurrentBranch(worktreePath: string): Promise<string> {
|
||||
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
||||
return branchOutput.trim();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Index Lock Recovery
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check whether an error message indicates a stale git index lock file.
|
||||
*
|
||||
* Git operations that write to the index (e.g. `git stash push`) will fail
|
||||
* with "could not write index" or "Unable to create ... .lock" when a
|
||||
* `.git/index.lock` file exists from a previously interrupted operation.
|
||||
*
|
||||
* @param errorMessage - The error string from a failed git command
|
||||
* @returns true if the error looks like a stale index lock issue
|
||||
*/
|
||||
export function isIndexLockError(errorMessage: string): boolean {
|
||||
const lower = errorMessage.toLowerCase();
|
||||
return (
|
||||
lower.includes('could not write index') ||
|
||||
(lower.includes('unable to create') && lower.includes('index.lock')) ||
|
||||
lower.includes('index.lock')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
|
||||
*
|
||||
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
|
||||
* which works for both regular repositories and linked worktrees.
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree (or main repo)
|
||||
* @returns true if a lock file was found and removed, false otherwise
|
||||
*/
|
||||
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
// Resolve the .git directory (handles worktrees correctly)
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
const lockFilePath = path.join(gitDir, 'index.lock');
|
||||
|
||||
// Check if the lock file exists
|
||||
try {
|
||||
await fs.access(lockFilePath);
|
||||
} catch {
|
||||
// Lock file does not exist — nothing to remove
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the stale lock file
|
||||
await fs.unlink(lockFilePath);
|
||||
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to remove stale index.lock file', {
|
||||
worktreePath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command with automatic retry when a stale index.lock is detected.
|
||||
*
|
||||
* If the command fails with an error indicating a locked index file, this
|
||||
* helper will attempt to remove the stale `.git/index.lock` and retry the
|
||||
* command exactly once.
|
||||
*
|
||||
* This is particularly useful for `git stash push` which writes to the
|
||||
* index and commonly fails when a previous git operation was interrupted.
|
||||
*
|
||||
* @param args - Array of git command arguments
|
||||
* @param cwd - Working directory to execute the command in
|
||||
* @param env - Optional additional environment variables
|
||||
* @returns Promise resolving to stdout output
|
||||
* @throws The original error if retry also fails, or a non-lock error
|
||||
*/
|
||||
export async function execGitCommandWithLockRetry(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await execGitCommand(args, cwd, env);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; stderr?: string };
|
||||
const errorMessage = err.stderr || err.message || '';
|
||||
|
||||
if (!isIndexLockError(errorMessage)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
|
||||
cwd,
|
||||
args: args.join(' '),
|
||||
});
|
||||
|
||||
const removed = await removeStaleIndexLock(cwd);
|
||||
if (!removed) {
|
||||
// Could not remove the lock file — re-throw the original error
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Retry the command once after removing the lock file
|
||||
return await execGitCommand(args, cwd, env);
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,18 @@ export interface PermissionCheckResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Minimal shape of a Cursor tool call used for permission checking */
|
||||
interface CursorToolCall {
|
||||
shellToolCall?: { args?: { command: string } };
|
||||
readToolCall?: { args?: { path: string } };
|
||||
writeToolCall?: { args?: { path: string } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool call is allowed based on permissions
|
||||
*/
|
||||
export function checkToolCallPermission(
|
||||
toolCall: any,
|
||||
toolCall: CursorToolCall,
|
||||
permissions: CursorCliConfigFile | null
|
||||
): PermissionCheckResult {
|
||||
if (!permissions || !permissions.permissions) {
|
||||
@@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean {
|
||||
/**
|
||||
* Log permission violations
|
||||
*/
|
||||
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
|
||||
export function logPermissionViolation(
|
||||
toolCall: CursorToolCall,
|
||||
reason: string,
|
||||
sessionId?: string
|
||||
): void {
|
||||
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
||||
|
||||
if (toolCall.shellToolCall?.args?.command) {
|
||||
|
||||
@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const,
|
||||
|
||||
/** Tools for chat/interactive mode */
|
||||
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
@@ -253,11 +261,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||
/**
|
||||
* Build thinking options for SDK configuration.
|
||||
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
|
||||
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
|
||||
* decide its own reasoning depth.
|
||||
*
|
||||
* @param thinkingLevel - The thinking level to convert
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
|
||||
*/
|
||||
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
if (!thinkingLevel || thinkingLevel === 'none') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
|
||||
// The model will use adaptive thinking by default
|
||||
if (thinkingLevel === 'adaptive') {
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Manual budget-based thinking for Haiku/Sonnet
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
|
||||
@@ -266,11 +290,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
|
||||
* - If there's a custom systemPrompt, appends it to the preset
|
||||
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
|
||||
* Build system prompt and settingSources based on two independent settings:
|
||||
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
|
||||
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
|
||||
*
|
||||
* These combine independently (4 possible states):
|
||||
* 1. Both ON: preset + settingSources (full Claude Code experience)
|
||||
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
|
||||
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
|
||||
* 4. Both OFF: plain string only
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with systemPrompt and settingSources for SDK options
|
||||
@@ -279,27 +307,34 @@ 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: {
|
||||
systemPrompt?: string | SystemPromptConfig;
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
} = {};
|
||||
|
||||
// Determine system prompt format based on useClaudeCodeSystemPrompt
|
||||
if (config.useClaudeCodeSystemPrompt) {
|
||||
// Use Claude Code's built-in system prompt as the base
|
||||
const presetConfig: SystemPromptConfig = {
|
||||
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) {
|
||||
presetConfig.append = config.systemPrompt;
|
||||
}
|
||||
result.systemPrompt = presetConfig;
|
||||
} else {
|
||||
// Standard mode - just pass through the system prompt as-is
|
||||
if (config.systemPrompt) {
|
||||
result.systemPrompt = config.systemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a custom system prompt, append it to the preset
|
||||
if (config.systemPrompt) {
|
||||
result.systemPrompt.append = config.systemPrompt;
|
||||
// Determine settingSources based on autoLoadClaudeMd
|
||||
if (config.autoLoadClaudeMd) {
|
||||
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
|
||||
result.settingSources = ['user', 'project'];
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -307,12 +342,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
|
||||
|
||||
/**
|
||||
* System prompt configuration for SDK options
|
||||
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
|
||||
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
|
||||
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
|
||||
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
|
||||
*/
|
||||
export interface SystemPromptConfig {
|
||||
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
|
||||
/** Use preset mode to select the base system prompt */
|
||||
type: 'preset';
|
||||
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
|
||||
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
|
||||
preset: 'claude_code';
|
||||
/** Optional additional prompt to append to the preset */
|
||||
append?: string;
|
||||
@@ -346,11 +383,19 @@ export interface CreateSdkOptionsConfig {
|
||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||
autoLoadClaudeMd?: boolean;
|
||||
|
||||
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
/** Extended thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
|
||||
/** Optional user-configured max turns override (from settings).
|
||||
* When provided, overrides the preset MAX_TURNS for the use case.
|
||||
* Range: 1-2000. */
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
// Re-export MCP types from @automaker/types for convenience
|
||||
@@ -387,7 +432,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt
|
||||
// See: https://github.com/AutoMaker-Org/automaker/issues/149
|
||||
permissionMode: 'default',
|
||||
model: getModelForUseCase('spec', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.specGeneration],
|
||||
...claudeMdOptions,
|
||||
@@ -421,7 +466,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig):
|
||||
// Override permissionMode - feature generation only needs read-only tools
|
||||
permissionMode: 'default',
|
||||
model: getModelForUseCase('features', config.model),
|
||||
maxTurns: MAX_TURNS.quick,
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
@@ -452,7 +497,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('suggestions', config.model),
|
||||
maxTurns: MAX_TURNS.extended,
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.readOnly],
|
||||
...claudeMdOptions,
|
||||
@@ -490,7 +535,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
...claudeMdOptions,
|
||||
@@ -525,7 +570,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
...claudeMdOptions,
|
||||
|
||||
@@ -33,9 +33,16 @@ import {
|
||||
|
||||
const logger = createLogger('SettingsHelper');
|
||||
|
||||
/** Default number of agent turns used when no value is configured. */
|
||||
export const DEFAULT_MAX_TURNS = 10000;
|
||||
|
||||
/** Upper bound for the max-turns clamp; values above this are capped here. */
|
||||
export const MAX_ALLOWED_TURNS = 10000;
|
||||
|
||||
/**
|
||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||
* Returns false if settings service is not available.
|
||||
* Falls back to global settings and defaults to true when unset.
|
||||
* Returns true if settings service is not available.
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param settingsService - Optional settings service instance
|
||||
@@ -48,8 +55,8 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
|
||||
return false;
|
||||
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -64,7 +71,7 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
|
||||
// Fall back to global settings
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.autoLoadClaudeMd ?? false;
|
||||
const result = globalSettings.autoLoadClaudeMd ?? true;
|
||||
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -73,6 +80,84 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
|
||||
* Falls back to global settings and defaults to true when unset.
|
||||
* Returns true 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., '[AgentService]')
|
||||
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
|
||||
*/
|
||||
export async function getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath: string,
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
logger.info(
|
||||
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check project settings first (takes precedence)
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
|
||||
logger.info(
|
||||
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
|
||||
);
|
||||
return projectSettings.useClaudeCodeSystemPrompt;
|
||||
}
|
||||
|
||||
// Fall back to global settings
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
|
||||
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default max turns setting from global settings.
|
||||
*
|
||||
* Reads the user's configured `defaultMaxTurns` setting, which controls the maximum
|
||||
* number of agent turns (tool-call round-trips) for feature execution.
|
||||
*
|
||||
* @param settingsService - Settings service instance (may be null)
|
||||
* @param logPrefix - Logging prefix for debugging
|
||||
* @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default
|
||||
*/
|
||||
export async function getDefaultMaxTurnsSetting(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<number> {
|
||||
if (!settingsService) {
|
||||
logger.info(
|
||||
`${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}`
|
||||
);
|
||||
return DEFAULT_MAX_TURNS;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const raw = globalSettings.defaultMaxTurns;
|
||||
const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS;
|
||||
// Clamp to valid range
|
||||
const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result)));
|
||||
logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`);
|
||||
return clamped;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error);
|
||||
return DEFAULT_MAX_TURNS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||
* and rebuilds the formatted prompt without it.
|
||||
|
||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Terminal Theme Data - Re-export terminal themes from platform package
|
||||
*
|
||||
* This module re-exports terminal theme data for use in the server.
|
||||
*/
|
||||
|
||||
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return getThemeColors(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all terminal themes
|
||||
*/
|
||||
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
|
||||
return terminalThemeColors;
|
||||
}
|
||||
|
||||
export default terminalThemeColors;
|
||||
@@ -78,7 +78,7 @@ export async function readWorktreeMetadata(
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as WorktreeMetadata;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
* with the provider architecture.
|
||||
*/
|
||||
|
||||
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getClaudeAuthIndicators } from '@automaker/platform';
|
||||
import {
|
||||
getThinkingTokenBudget,
|
||||
validateBareModelId,
|
||||
@@ -17,6 +16,14 @@ import {
|
||||
type ClaudeCompatibleProvider,
|
||||
type Credentials,
|
||||
} from '@automaker/types';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
|
||||
/**
|
||||
* ProviderConfig - Union type for provider configuration
|
||||
@@ -25,29 +32,11 @@ import {
|
||||
* Both share the same connection settings structure.
|
||||
*/
|
||||
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
// Authentication
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
// Endpoint configuration
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'API_TIMEOUT_MS',
|
||||
// Model mappings
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
// Traffic control
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
// System vars (always from process.env)
|
||||
// System vars are always passed from process.env regardless of profile.
|
||||
// Includes filesystem, locale, and temp directory vars that the Claude CLI
|
||||
// needs internally for config resolution and temp file creation.
|
||||
const SYSTEM_ENV_VARS = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
@@ -55,11 +44,13 @@ const ALLOWED_ENV_VARS = [
|
||||
'USER',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'TMPDIR',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_STATE_HOME',
|
||||
];
|
||||
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
|
||||
/**
|
||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||
* by checking for the 'models' array property
|
||||
@@ -204,7 +195,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
model,
|
||||
cwd,
|
||||
systemPrompt,
|
||||
maxTurns = 20,
|
||||
maxTurns = 1000,
|
||||
allowedTools,
|
||||
abortController,
|
||||
conversationHistory,
|
||||
@@ -219,8 +210,11 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// claudeCompatibleProvider takes precedence over claudeApiProfile
|
||||
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
// Build thinking configuration
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
|
||||
// Manual thinking (Haiku/Sonnet): use budget_tokens
|
||||
const maxThinkingTokens =
|
||||
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
const sdkOptions: Options = {
|
||||
@@ -234,6 +228,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
env: buildEnv(providerConfig, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// Restrict available built-in tools if specified (tools: [] disables all tools)
|
||||
...(options.tools && { tools: options.tools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
@@ -255,14 +251,14 @@ export class ClaudeProvider extends BaseProvider {
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
let promptPayload: string | AsyncIterable<any>;
|
||||
let promptPayload: string | AsyncIterable<SDKUserMessage>;
|
||||
|
||||
if (Array.isArray(prompt)) {
|
||||
// Multi-part prompt (with images)
|
||||
promptPayload = (async function* () {
|
||||
const multiPartPrompt = {
|
||||
const multiPartPrompt: SDKUserMessage = {
|
||||
type: 'user' as const,
|
||||
session_id: '',
|
||||
session_id: sdkSessionId || '',
|
||||
message: {
|
||||
role: 'user' as const,
|
||||
content: prompt,
|
||||
@@ -314,12 +310,16 @@ export class ClaudeProvider extends BaseProvider {
|
||||
? `${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;
|
||||
const enhancedError = new Error(message) as Error & {
|
||||
originalError: unknown;
|
||||
type: string;
|
||||
retryAfter?: number;
|
||||
};
|
||||
enhancedError.originalError = error;
|
||||
enhancedError.type = errorInfo.type;
|
||||
|
||||
if (errorInfo.isRateLimit) {
|
||||
(enhancedError as any).retryAfter = errorInfo.retryAfter;
|
||||
enhancedError.retryAfter = errorInfo.retryAfter;
|
||||
}
|
||||
|
||||
throw enhancedError;
|
||||
@@ -331,13 +331,37 @@ export class ClaudeProvider extends BaseProvider {
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
// Claude SDK is always available since it's a dependency
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
// Check all four supported auth methods, mirroring the logic in buildEnv():
|
||||
// 1. ANTHROPIC_API_KEY environment variable
|
||||
// 2. ANTHROPIC_AUTH_TOKEN environment variable
|
||||
// 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators)
|
||||
// 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators)
|
||||
const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
// Check credentials file and CLI OAuth indicators (same sources used by buildEnv)
|
||||
let hasCredentialsApiKey = false;
|
||||
let hasCliOAuth = false;
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
hasCredentialsApiKey = !!indicators.credentials?.hasApiKey;
|
||||
hasCliOAuth = !!(
|
||||
indicators.credentials?.hasOAuthToken ||
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions)
|
||||
);
|
||||
} catch {
|
||||
// If we can't check indicators, fall back to env vars only
|
||||
}
|
||||
|
||||
const hasApiKey = hasEnvApiKey || hasCredentialsApiKey;
|
||||
const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth;
|
||||
|
||||
const status: InstallationStatus = {
|
||||
installed: true,
|
||||
method: 'sdk',
|
||||
hasApiKey,
|
||||
authenticated: hasApiKey,
|
||||
authenticated,
|
||||
};
|
||||
|
||||
return status;
|
||||
@@ -349,18 +373,30 @@ export class ClaudeProvider extends BaseProvider {
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
const models = [
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
modelString: 'claude-opus-4-6',
|
||||
provider: 'anthropic',
|
||||
description: 'Most capable Claude model',
|
||||
description: 'Most capable Claude model with adaptive thinking',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
maxOutputTokens: 128000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
modelString: 'claude-sonnet-4-6',
|
||||
provider: 'anthropic',
|
||||
description: 'Balanced performance and cost with enhanced reasoning',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
name: 'Claude Sonnet 4',
|
||||
|
||||
@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
|
||||
export const CODEX_MODELS: ModelDefinition[] = [
|
||||
// ========== Recommended Codex Models ==========
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
id: CODEX_MODEL_MAP.gpt53Codex,
|
||||
name: 'GPT-5.3-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt53Codex,
|
||||
provider: 'openai',
|
||||
description:
|
||||
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -33,12 +32,38 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
default: true,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||
name: 'GPT-5.3-Codex-Spark',
|
||||
modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
|
||||
provider: 'openai',
|
||||
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
provider: 'openai',
|
||||
description: 'Frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
name: 'GPT-5.1-Codex-Max',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
provider: 'openai',
|
||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
|
||||
description: 'Codex-optimized flagship for deep and fast reasoning.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -51,7 +76,46 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.1-Codex-Mini',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
|
||||
provider: 'openai',
|
||||
description: 'Smaller, more cost-effective version for faster workflows.',
|
||||
description: 'Optimized for codex. Cheaper, faster, but less capable.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'basic' as const,
|
||||
hasReasoning: false,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51Codex,
|
||||
name: 'GPT-5.1-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt51Codex,
|
||||
provider: 'openai',
|
||||
description: 'Original GPT-5.1 Codex agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5Codex,
|
||||
name: 'GPT-5-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt5Codex,
|
||||
provider: 'openai',
|
||||
description: 'Original GPT-5 Codex model.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||
name: 'GPT-5-Codex-Mini',
|
||||
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
|
||||
provider: 'openai',
|
||||
description: 'Smaller, cheaper GPT-5 Codex variant.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
@@ -66,7 +130,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.2',
|
||||
modelString: CODEX_MODEL_MAP.gpt52,
|
||||
provider: 'openai',
|
||||
description: 'Best general agentic model for tasks across industries and domains.',
|
||||
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -87,6 +151,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt5,
|
||||
name: 'GPT-5',
|
||||
modelString: CODEX_MODEL_MAP.gpt5,
|
||||
provider: 'openai',
|
||||
description: 'Base GPT-5 model.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,11 +30,9 @@ import type {
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
import {
|
||||
CODEX_MODEL_MAP,
|
||||
supportsReasoningEffort,
|
||||
validateBareModelId,
|
||||
calculateReasoningTimeout,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type CodexApprovalPolicy,
|
||||
type CodexSandboxMode,
|
||||
type CodexAuthStatus,
|
||||
@@ -53,18 +51,14 @@ import { CODEX_MODELS } from './codex-models.js';
|
||||
|
||||
const CODEX_COMMAND = 'codex';
|
||||
const CODEX_EXEC_SUBCOMMAND = 'exec';
|
||||
const CODEX_RESUME_SUBCOMMAND = 'resume';
|
||||
const CODEX_JSON_FLAG = '--json';
|
||||
const CODEX_MODEL_FLAG = '--model';
|
||||
const CODEX_VERSION_FLAG = '--version';
|
||||
const CODEX_SANDBOX_FLAG = '--sandbox';
|
||||
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
|
||||
const CODEX_SEARCH_FLAG = '--search';
|
||||
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
||||
const CODEX_CONFIG_FLAG = '--config';
|
||||
const CODEX_IMAGE_FLAG = '--image';
|
||||
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
||||
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
||||
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
||||
const CODEX_RESUME_FLAG = 'resume';
|
||||
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
||||
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -104,11 +98,8 @@ const TEXT_ENCODING = 'utf-8';
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
||||
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
||||
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
||||
@@ -136,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const;
|
||||
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
||||
const MIN_MAX_TURNS = 1;
|
||||
@@ -210,16 +206,42 @@ function isSdkEligible(options: ExecuteOptions): boolean {
|
||||
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
||||
}
|
||||
|
||||
function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
|
||||
// When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
|
||||
// SDK mode is used when MCP servers are not configured (MCP requires CLI).
|
||||
// Tool requests are handled by the SDK, so we allow SDK mode even with tools.
|
||||
return !hasMcpServersConfigured(options);
|
||||
}
|
||||
|
||||
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
||||
const cliPath = await findCodexCliPath();
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
const openAiApiKey = await resolveOpenAiApiKey();
|
||||
const hasApiKey = Boolean(openAiApiKey);
|
||||
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
||||
const sdkEligible = isSdkEligible(options);
|
||||
const cliAvailable = Boolean(cliPath);
|
||||
// CLI OAuth login takes priority: if the user has logged in via `codex login`,
|
||||
// use the CLI regardless of whether an API key is also stored.
|
||||
// hasOAuthToken = OAuth session from `codex login`
|
||||
// authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
|
||||
// Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
|
||||
const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
|
||||
const sdkEligible = isSdkEligible(options);
|
||||
|
||||
if (hasApiKey) {
|
||||
// If CLI is available and the user authenticated via the CLI (`codex login`),
|
||||
// prefer CLI mode over SDK. This ensures `codex login` sessions take priority
|
||||
// over API keys stored in Automaker's credentials.
|
||||
if (cliAvailable && hasCliNativeAuth) {
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_CLI,
|
||||
cliPath,
|
||||
openAiApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// No CLI-native auth — prefer SDK when an API key is available.
|
||||
// Using SDK with an API key avoids OAuth issues that can arise with the CLI.
|
||||
// MCP servers still require CLI mode since the SDK doesn't support MCP.
|
||||
if (hasApiKey && isSdkEligibleWithApiKey(options)) {
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_SDK,
|
||||
cliPath,
|
||||
@@ -227,6 +249,16 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
||||
};
|
||||
}
|
||||
|
||||
// MCP servers are requested with an API key but no CLI-native auth — use CLI mode
|
||||
// with the API key passed as an environment variable.
|
||||
if (hasApiKey && cliAvailable) {
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_CLI,
|
||||
cliPath,
|
||||
openAiApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (sdkEligible) {
|
||||
if (!cliAvailable) {
|
||||
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
||||
@@ -237,15 +269,9 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
|
||||
throw new Error(ERROR_CODEX_CLI_REQUIRED);
|
||||
}
|
||||
|
||||
if (!cliAuthenticated) {
|
||||
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: CODEX_EXECUTION_MODE_CLI,
|
||||
cliPath,
|
||||
openAiApiKey,
|
||||
};
|
||||
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
|
||||
// so authentication is required regardless.
|
||||
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
||||
}
|
||||
|
||||
function getEventType(event: Record<string, unknown>): string | null {
|
||||
@@ -335,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPromptText(options: ExecuteOptions): string {
|
||||
return typeof options.prompt === 'string'
|
||||
? options.prompt
|
||||
: extractTextFromContent(options.prompt);
|
||||
}
|
||||
|
||||
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
|
||||
const promptText =
|
||||
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
|
||||
const promptText = buildPromptText(options);
|
||||
const historyText = options.conversationHistory
|
||||
? formatHistoryAsText(options.conversationHistory)
|
||||
: '';
|
||||
@@ -350,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
|
||||
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
|
||||
}
|
||||
|
||||
function buildResumePrompt(options: ExecuteOptions): string {
|
||||
const promptText = buildPromptText(options);
|
||||
return `${HISTORY_HEADER}${promptText}`;
|
||||
}
|
||||
|
||||
function formatConfigValue(value: string | number | boolean): string {
|
||||
return String(value);
|
||||
}
|
||||
@@ -717,6 +753,16 @@ export class CodexProvider extends BaseProvider {
|
||||
);
|
||||
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
||||
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
||||
if (resolvedMaxTurns === null && options.maxTurns === undefined) {
|
||||
logger.warn(
|
||||
`[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` +
|
||||
`This may cause premature completion. Model: ${options.model}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}`
|
||||
);
|
||||
}
|
||||
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
||||
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
||||
const wantsOutputSchema = Boolean(
|
||||
@@ -758,24 +804,27 @@ export class CodexProvider extends BaseProvider {
|
||||
options.cwd,
|
||||
codexSettings.sandboxMode !== 'danger-full-access'
|
||||
);
|
||||
const resolvedSandboxMode = sandboxCheck.enabled
|
||||
? codexSettings.sandboxMode
|
||||
: 'danger-full-access';
|
||||
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
||||
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
||||
}
|
||||
const searchEnabled =
|
||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
||||
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
||||
const isResumeQuery = Boolean(options.sdkSessionId);
|
||||
const schemaPath = isResumeQuery
|
||||
? null
|
||||
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const imageBlocks =
|
||||
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
|
||||
const approvalPolicy =
|
||||
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
||||
? options.mcpAutoApproveTools
|
||||
? 'never'
|
||||
: 'on-request'
|
||||
: codexSettings.approvalPolicy;
|
||||
const promptText = buildCombinedPrompt(options, combinedSystemPrompt);
|
||||
const promptText = isResumeQuery
|
||||
? buildResumePrompt(options)
|
||||
: buildCombinedPrompt(options, combinedSystemPrompt);
|
||||
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
|
||||
|
||||
// Build config overrides for max turns and reasoning effort
|
||||
@@ -801,25 +850,43 @@ export class CodexProvider extends BaseProvider {
|
||||
overrides.push({ key: 'features.web_search_request', value: true });
|
||||
}
|
||||
|
||||
const configOverrides = buildConfigOverrides(overrides);
|
||||
const configOverrideArgs = buildConfigOverrides(overrides);
|
||||
const preExecArgs: string[] = [];
|
||||
|
||||
// Add additional directories with write access
|
||||
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) {
|
||||
if (
|
||||
!isResumeQuery &&
|
||||
codexSettings.additionalDirs &&
|
||||
codexSettings.additionalDirs.length > 0
|
||||
) {
|
||||
for (const dir of codexSettings.additionalDirs) {
|
||||
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
|
||||
}
|
||||
}
|
||||
|
||||
// If images were written to disk, add the image directory so the CLI can access them.
|
||||
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
|
||||
if (imagePaths.length > 0) {
|
||||
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
|
||||
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
|
||||
}
|
||||
|
||||
// Model is already bare (no prefix) - validated by executeQuery
|
||||
const codexCommand = isResumeQuery
|
||||
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
|
||||
: [CODEX_EXEC_SUBCOMMAND];
|
||||
|
||||
const args = [
|
||||
CODEX_EXEC_SUBCOMMAND,
|
||||
...codexCommand,
|
||||
CODEX_YOLO_FLAG,
|
||||
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
|
||||
...preExecArgs,
|
||||
CODEX_MODEL_FLAG,
|
||||
options.model,
|
||||
CODEX_JSON_FLAG,
|
||||
...configOverrideArgs,
|
||||
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
|
||||
...(options.sdkSessionId ? [options.sdkSessionId] : []),
|
||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||
];
|
||||
|
||||
@@ -866,16 +933,36 @@ export class CodexProvider extends BaseProvider {
|
||||
|
||||
// Enhance error message with helpful context
|
||||
let enhancedError = errorText;
|
||||
if (errorText.toLowerCase().includes('rate limit')) {
|
||||
const errorLower = errorText.toLowerCase();
|
||||
if (errorLower.includes('rate limit')) {
|
||||
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
||||
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
|
||||
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
|
||||
} else if (
|
||||
errorText.toLowerCase().includes('authentication') ||
|
||||
errorText.toLowerCase().includes('unauthorized')
|
||||
errorLower.includes('model does not exist') ||
|
||||
errorLower.includes('requested model does not exist') ||
|
||||
errorLower.includes('do not have access') ||
|
||||
errorLower.includes('model_not_found') ||
|
||||
errorLower.includes('invalid_model')
|
||||
) {
|
||||
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
|
||||
enhancedError =
|
||||
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
|
||||
`See https://platform.openai.com/docs/models for available models. ` +
|
||||
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
|
||||
} else if (
|
||||
errorText.toLowerCase().includes('not found') ||
|
||||
errorText.toLowerCase().includes('command not found')
|
||||
errorLower.includes('stream disconnected') ||
|
||||
errorLower.includes('stream ended') ||
|
||||
errorLower.includes('connection reset')
|
||||
) {
|
||||
enhancedError =
|
||||
`${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||
`- Network instability\n` +
|
||||
`- The model not being available on your plan\n` +
|
||||
`- Server-side timeouts for long-running requests\n` +
|
||||
`Try again, or switch to a different model.`;
|
||||
} else if (
|
||||
errorLower.includes('command not found') ||
|
||||
errorLower.includes('is not recognized as an internal or external command')
|
||||
) {
|
||||
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
||||
}
|
||||
@@ -1033,7 +1120,6 @@ export class CodexProvider extends BaseProvider {
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const cliPath = await findCodexCliPath();
|
||||
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
const installed = !!cliPath;
|
||||
|
||||
let version = '';
|
||||
@@ -1045,7 +1131,7 @@ export class CodexProvider extends BaseProvider {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
version = result.stdout.trim();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
version = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
|
||||
const DEFAULT_RESPONSE_TEXT = '';
|
||||
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
||||
|
||||
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
|
||||
type PromptBlock = {
|
||||
type: string;
|
||||
text?: string;
|
||||
@@ -99,38 +102,52 @@ export async function* executeCodexSdkQuery(
|
||||
const apiKey = resolveApiKey();
|
||||
const codex = new Codex({ apiKey });
|
||||
|
||||
// Build thread options with model
|
||||
// The model must be passed to startThread/resumeThread so the SDK
|
||||
// knows which model to use for the conversation. Without this,
|
||||
// the SDK may use a default model that the user doesn't have access to.
|
||||
const threadOptions: {
|
||||
model?: string;
|
||||
modelReasoningEffort?: SdkReasoningEffort;
|
||||
} = {};
|
||||
|
||||
if (options.model) {
|
||||
threadOptions.model = options.model;
|
||||
}
|
||||
|
||||
// Add reasoning effort to thread options if model supports it
|
||||
if (
|
||||
options.reasoningEffort &&
|
||||
options.model &&
|
||||
supportsReasoningEffort(options.model) &&
|
||||
options.reasoningEffort !== 'none' &&
|
||||
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
|
||||
) {
|
||||
threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
|
||||
}
|
||||
|
||||
// Resume existing thread or start new one
|
||||
let thread;
|
||||
if (options.sdkSessionId) {
|
||||
try {
|
||||
thread = codex.resumeThread(options.sdkSessionId);
|
||||
thread = codex.resumeThread(options.sdkSessionId, threadOptions);
|
||||
} catch {
|
||||
// If resume fails, start a new thread
|
||||
thread = codex.startThread();
|
||||
thread = codex.startThread(threadOptions);
|
||||
}
|
||||
} else {
|
||||
thread = codex.startThread();
|
||||
thread = codex.startThread(threadOptions);
|
||||
}
|
||||
|
||||
const promptText = buildPromptText(options, systemPrompt);
|
||||
|
||||
// Build run options with reasoning effort if supported
|
||||
// Build run options
|
||||
const runOptions: {
|
||||
signal?: AbortSignal;
|
||||
reasoning?: { effort: string };
|
||||
} = {
|
||||
signal: options.abortController?.signal,
|
||||
};
|
||||
|
||||
// Add reasoning effort if model supports it and reasoningEffort is specified
|
||||
if (
|
||||
options.reasoningEffort &&
|
||||
supportsReasoningEffort(options.model) &&
|
||||
options.reasoningEffort !== 'none'
|
||||
) {
|
||||
runOptions.reasoning = { effort: options.reasoningEffort };
|
||||
}
|
||||
|
||||
// Run the query
|
||||
const result = await thread.run(promptText, runOptions);
|
||||
|
||||
@@ -160,10 +177,42 @@ export async function* executeCodexSdkQuery(
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
const userMessage = getUserFriendlyErrorMessage(error);
|
||||
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
||||
let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
||||
|
||||
// Enhance error messages with actionable tips for common Codex issues
|
||||
// Normalize inputs to avoid crashes from nullish values
|
||||
const errorLower = (errorInfo?.message ?? '').toLowerCase();
|
||||
const modelLabel = options?.model ?? '<unknown model>';
|
||||
|
||||
if (
|
||||
errorLower.includes('does not exist') ||
|
||||
errorLower.includes('model_not_found') ||
|
||||
errorLower.includes('invalid_model')
|
||||
) {
|
||||
// Model not found - provide helpful guidance
|
||||
combinedMessage +=
|
||||
`\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` +
|
||||
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
|
||||
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
|
||||
} else if (
|
||||
errorLower.includes('stream disconnected') ||
|
||||
errorLower.includes('stream ended') ||
|
||||
errorLower.includes('connection reset') ||
|
||||
errorLower.includes('socket hang up')
|
||||
) {
|
||||
// Stream disconnection - provide helpful guidance
|
||||
combinedMessage +=
|
||||
`\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
|
||||
`- Network instability\n` +
|
||||
`- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
|
||||
`- Server-side timeouts for long-running requests\n` +
|
||||
`Try again, or switch to a different model.`;
|
||||
}
|
||||
|
||||
console.error('[CodexSDK] executeQuery() error during execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
model: options.model,
|
||||
isRateLimit: errorInfo.isRateLimit,
|
||||
retryAfter: errorInfo.retryAfter,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
@@ -85,10 +86,6 @@ interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkSessionIdleEvent extends SdkEvent {
|
||||
type: 'session.idle';
|
||||
}
|
||||
|
||||
interface SdkSessionErrorEvent extends SdkEvent {
|
||||
type: 'session.error';
|
||||
data: {
|
||||
@@ -120,6 +117,12 @@ export interface CopilotError extends Error {
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
|
||||
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
|
||||
type ResumableCopilotClient = CopilotClient & {
|
||||
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
@@ -386,9 +389,14 @@ export class CopilotProvider extends CliProvider {
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
const enrichedError =
|
||||
errorEvent.data.message ||
|
||||
(errorEvent.data.code
|
||||
? `Copilot agent error (code: ${errorEvent.data.code})`
|
||||
: 'Copilot agent error');
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -520,7 +528,11 @@ export class CopilotProvider extends CliProvider {
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
// resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
|
||||
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
|
||||
// Normalize by converting the last dash-separated numeric pair to dot notation.
|
||||
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
|
||||
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
@@ -558,12 +570,14 @@ export class CopilotProvider extends CliProvider {
|
||||
});
|
||||
};
|
||||
|
||||
// Declare session outside try so it's accessible in the catch block for cleanup.
|
||||
let session: CopilotSession | undefined;
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||
|
||||
// Create session with streaming enabled for real-time events
|
||||
const session = await client.createSession({
|
||||
const sessionOptions: CopilotSessionOptions = {
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
@@ -576,13 +590,33 @@ export class CopilotProvider extends CliProvider {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
// Resume the previous Copilot session when possible; otherwise create a fresh one.
|
||||
const resumableClient = client as ResumableCopilotClient;
|
||||
let sessionResumed = false;
|
||||
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
|
||||
try {
|
||||
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
|
||||
sessionResumed = true;
|
||||
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
|
||||
} catch (resumeError) {
|
||||
logger.warn(
|
||||
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
|
||||
);
|
||||
session = await client.createSession(sessionOptions);
|
||||
}
|
||||
} else {
|
||||
session = await client.createSession(sessionOptions);
|
||||
}
|
||||
|
||||
// session is always assigned by this point (both branches above assign it)
|
||||
const activeSession = session!;
|
||||
const sessionId = activeSession.sessionId;
|
||||
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
activeSession.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
@@ -600,7 +634,7 @@ export class CopilotProvider extends CliProvider {
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
await activeSession.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
@@ -608,7 +642,7 @@ export class CopilotProvider extends CliProvider {
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await activeSession.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
@@ -628,11 +662,19 @@ export class CopilotProvider extends CliProvider {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await activeSession.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
// Ensure session is destroyed and client is stopped on error to prevent leaks.
|
||||
// The session may have been created/resumed before the error occurred.
|
||||
if (session) {
|
||||
try {
|
||||
await session.destroy();
|
||||
} catch (sessionCleanupError) {
|
||||
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
|
||||
import {
|
||||
CliProvider,
|
||||
type CliSpawnConfig,
|
||||
@@ -30,7 +31,7 @@ import type {
|
||||
} from './types.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { validateApiKey } from '../lib/auth-utils.js';
|
||||
import { getEffectivePermissions } from '../services/cursor-config-service.js';
|
||||
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
|
||||
import {
|
||||
type CursorStreamEvent,
|
||||
type CursorSystemEvent,
|
||||
@@ -68,6 +69,7 @@ interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
|
||||
* Registry of Cursor tool handlers
|
||||
* Each handler knows how to normalize its specific tool call type
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
|
||||
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
|
||||
readToolCall: {
|
||||
name: 'Read',
|
||||
@@ -286,15 +288,113 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
||||
windowsStrategy: 'direct',
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||
// Windows paths are not used - we check for WSL installation instead
|
||||
win32: [],
|
||||
win32: [
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -350,6 +450,11 @@ export class CursorProvider extends CliProvider {
|
||||
cliArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Resume an existing chat when a provider session ID is available
|
||||
if (options.sdkSessionId) {
|
||||
cliArgs.push('--resume', options.sdkSessionId);
|
||||
}
|
||||
|
||||
// Use '-' to indicate reading prompt from stdin
|
||||
cliArgs.push('-');
|
||||
|
||||
@@ -457,10 +562,14 @@ export class CursorProvider extends CliProvider {
|
||||
const resultEvent = cursorEvent as CursorResultEvent;
|
||||
|
||||
if (resultEvent.is_error) {
|
||||
const errorText = resultEvent.error || resultEvent.result || '';
|
||||
const enrichedError =
|
||||
errorText ||
|
||||
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || resultEvent.result || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,6 +596,92 @@ export class CursorProvider extends CliProvider {
|
||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
if (process.platform === 'win32') {
|
||||
const findInPath = (command: string): string | null => {
|
||||
try {
|
||||
const result = execSync(`where ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim()
|
||||
.split(/\r?\n/)[0];
|
||||
|
||||
if (result && fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCursorAgentBinary = (cliPath: string) =>
|
||||
cliPath.toLowerCase().includes('cursor-agent');
|
||||
|
||||
const supportsCursorAgentSubcommand = (cliPath: string) => {
|
||||
try {
|
||||
execSync(`"${cliPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
|
||||
if (pathResult) {
|
||||
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
|
||||
return {
|
||||
cliPath: pathResult,
|
||||
useWsl: false,
|
||||
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.getSpawnConfig();
|
||||
for (const candidate of config.commonPaths.win32 || []) {
|
||||
const resolved = candidate;
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
|
||||
return {
|
||||
cliPath: resolved,
|
||||
useWsl: false,
|
||||
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wslLogger = (msg: string) => logger.debug(msg);
|
||||
if (isWslAvailable({ logger: wslLogger })) {
|
||||
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||
if (wslResult) {
|
||||
logger.debug(
|
||||
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||
);
|
||||
return {
|
||||
cliPath: 'wsl.exe',
|
||||
useWsl: true,
|
||||
wslCliPath: wslResult.wslPath,
|
||||
wslDistribution: wslResult.distribution,
|
||||
strategy: 'wsl',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cursor-agent not found on Windows');
|
||||
return { cliPath: null, useWsl: false, strategy: 'direct' };
|
||||
}
|
||||
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
const result = super.detectCli();
|
||||
if (result.cliPath) {
|
||||
@@ -495,7 +690,7 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// Cursor-specific: Check versions directory for any installed version
|
||||
// This handles cases where cursor-agent is installed but not in PATH
|
||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
@@ -521,33 +716,31 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||
if (process.platform !== 'win32') {
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -694,8 +887,12 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
// Get effective permissions for this project
|
||||
// Get effective permissions for this project and detect the active profile
|
||||
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
||||
const activeProfile = detectProfile(effectivePermissions);
|
||||
logger.debug(
|
||||
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
|
||||
);
|
||||
|
||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||
const debugRawEvents =
|
||||
|
||||
@@ -20,12 +20,11 @@ import type {
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
@@ -264,6 +263,14 @@ export class GeminiProvider extends CliProvider {
|
||||
// Use explicit approval-mode for clearer semantics
|
||||
cliArgs.push('--approval-mode', 'yolo');
|
||||
|
||||
// Force headless (non-interactive) mode with --prompt flag.
|
||||
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
|
||||
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
|
||||
// starts in interactive mode which adds significant startup overhead
|
||||
// (interactive REPL setup, extra context loading, etc.).
|
||||
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
|
||||
cliArgs.push('--prompt', '');
|
||||
|
||||
// Explicitly include the working directory in allowed workspace directories
|
||||
// This ensures Gemini CLI allows file operations in the project directory,
|
||||
// even if it has a different workspace cached from a previous session
|
||||
@@ -271,13 +278,15 @@ export class GeminiProvider extends CliProvider {
|
||||
cliArgs.push('--include-directories', options.cwd);
|
||||
}
|
||||
|
||||
// Resume an existing Gemini session when one is available
|
||||
if (options.sdkSessionId) {
|
||||
cliArgs.push('--resume', options.sdkSessionId);
|
||||
}
|
||||
|
||||
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
||||
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
||||
// The model handles thinking internally based on the task complexity.
|
||||
|
||||
// The prompt will be passed as the last positional argument
|
||||
// We'll append it in executeQuery after extracting the text
|
||||
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
@@ -372,10 +381,13 @@ export class GeminiProvider extends CliProvider {
|
||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||
|
||||
if (resultEvent.status === 'error') {
|
||||
const enrichedError =
|
||||
resultEvent.error ||
|
||||
`Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,10 +404,12 @@ export class GeminiProvider extends CliProvider {
|
||||
|
||||
case 'error': {
|
||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||
const enrichedError =
|
||||
errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,6 +423,32 @@ export class GeminiProvider extends CliProvider {
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
|
||||
*
|
||||
* Passes the prompt via stdin instead of --prompt CLI arg to:
|
||||
* - Avoid shell argument size limits with large prompts (system prompt + context)
|
||||
* - Avoid shell escaping issues with special characters in prompts
|
||||
* - Match the pattern used by Cursor, OpenCode, and Codex providers
|
||||
*
|
||||
* Also injects environment variables to reduce Gemini CLI startup overhead:
|
||||
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
|
||||
*/
|
||||
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
// and shell argument size limits with large system prompts + context files
|
||||
subprocessOptions.stdinData = this.extractPromptText(options);
|
||||
|
||||
// Disable telemetry to reduce startup overhead
|
||||
if (subprocessOptions.env) {
|
||||
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
|
||||
}
|
||||
|
||||
return subprocessOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override error mapping for Gemini-specific error codes
|
||||
*/
|
||||
@@ -518,14 +558,21 @@ export class GeminiProvider extends CliProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass as positional argument
|
||||
const promptText = this.extractPromptText(options);
|
||||
// Ensure .geminiignore exists in the working directory to prevent Gemini CLI
|
||||
// from scanning .git and node_modules directories during startup. This reduces
|
||||
// startup time significantly (reported: 35s → 11s) by skipping large directories
|
||||
// that Gemini CLI would otherwise traverse for context discovery.
|
||||
await this.ensureGeminiIgnore(options.cwd || process.cwd());
|
||||
|
||||
// Build CLI args and append the prompt as the last positional argument
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
||||
// Embed system prompt into the user prompt so Gemini CLI receives
|
||||
// project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
|
||||
// otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
// Build CLI args for headless execution.
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
@@ -578,6 +625,49 @@ export class GeminiProvider extends CliProvider {
|
||||
// Gemini-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Ensure a .geminiignore file exists in the working directory.
|
||||
*
|
||||
* Gemini CLI scans the working directory for context discovery during startup.
|
||||
* Excluding .git and node_modules dramatically reduces startup time by preventing
|
||||
* traversal of large directories (reported improvement: 35s → 11s).
|
||||
*
|
||||
* Only creates the file if it doesn't already exist to avoid overwriting user config.
|
||||
*/
|
||||
private async ensureGeminiIgnore(cwd: string): Promise<void> {
|
||||
const ignorePath = path.join(cwd, '.geminiignore');
|
||||
const content = [
|
||||
'# Auto-generated by Automaker to speed up Gemini CLI startup',
|
||||
'# Prevents Gemini CLI from scanning large directories during context discovery',
|
||||
'.git',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'coverage',
|
||||
'.automaker',
|
||||
'.worktrees',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'*.lock',
|
||||
'',
|
||||
].join('\n');
|
||||
try {
|
||||
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
|
||||
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
|
||||
logger.debug(`Created .geminiignore at ${ignorePath}`);
|
||||
} catch (writeError) {
|
||||
// EEXIST means file already exists - that's fine, preserve user's file
|
||||
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
|
||||
return;
|
||||
}
|
||||
// Non-fatal: startup will just be slower without the ignore file
|
||||
logger.debug(`Failed to create .geminiignore: ${writeError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GeminiError with details
|
||||
*/
|
||||
|
||||
@@ -192,6 +192,28 @@ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
||||
part?: OpenCodePart & { error: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked.
|
||||
* Contains the tool name, call ID, and the complete state (input, output, status).
|
||||
* Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type.
|
||||
*/
|
||||
export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool_use';
|
||||
part: OpenCodePart & {
|
||||
type: 'tool';
|
||||
callID?: string;
|
||||
tool?: string;
|
||||
state?: {
|
||||
status?: string;
|
||||
input?: unknown;
|
||||
output?: string;
|
||||
title?: string;
|
||||
metadata?: unknown;
|
||||
time?: { start: number; end: number };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all OpenCode stream events
|
||||
*/
|
||||
@@ -200,6 +222,7 @@ export type OpenCodeStreamEvent =
|
||||
| OpenCodeStepStartEvent
|
||||
| OpenCodeStepFinishEvent
|
||||
| OpenCodeToolCallEvent
|
||||
| OpenCodeToolUseEvent
|
||||
| OpenCodeToolResultEvent
|
||||
| OpenCodeErrorEvent
|
||||
| OpenCodeToolErrorEvent;
|
||||
@@ -311,8 +334,8 @@ export class OpencodeProvider extends CliProvider {
|
||||
* Arguments built:
|
||||
* - 'run' subcommand for executing queries
|
||||
* - '--format', 'json' for JSONL streaming output
|
||||
* - '-c', '<cwd>' for working directory (using opencode's -c flag)
|
||||
* - '--model', '<model>' for model selection (if specified)
|
||||
* - '--session', '<id>' for continuing an existing session (if sdkSessionId is set)
|
||||
*
|
||||
* The prompt is passed via stdin (piped) to avoid shell escaping issues.
|
||||
* OpenCode CLI automatically reads from stdin when input is piped.
|
||||
@@ -326,6 +349,14 @@ export class OpencodeProvider extends CliProvider {
|
||||
// Add JSON output format for JSONL parsing (not 'stream-json')
|
||||
args.push('--format', 'json');
|
||||
|
||||
// Handle session resumption for conversation continuity.
|
||||
// The opencode CLI supports `--session <id>` to continue an existing session.
|
||||
// The sdkSessionId is captured from the sessionID field in previous stream events
|
||||
// and persisted by AgentService for use in follow-up messages.
|
||||
if (options.sdkSessionId) {
|
||||
args.push('--session', options.sdkSessionId);
|
||||
}
|
||||
|
||||
// Handle model selection
|
||||
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
||||
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
||||
@@ -398,15 +429,225 @@ export class OpencodeProvider extends CliProvider {
|
||||
return subprocessOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a session-not-found condition.
|
||||
*
|
||||
* Centralizes the pattern matching for session errors to avoid duplication.
|
||||
* Strips ANSI escape codes first since opencode CLI uses colored stderr output
|
||||
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found").
|
||||
*
|
||||
* IMPORTANT: Patterns must be specific enough to avoid false positives.
|
||||
* Generic patterns like "notfounderror" or "resource not found" match
|
||||
* non-session errors (e.g. "ProviderModelNotFoundError") which would
|
||||
* trigger unnecessary retries that fail identically, producing confusing
|
||||
* error messages like "OpenCode session could not be created".
|
||||
*
|
||||
* @param errorText - Raw error text (may contain ANSI codes)
|
||||
* @returns true if the error indicates the session was not found
|
||||
*/
|
||||
private static isSessionNotFoundError(errorText: string): boolean {
|
||||
const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase();
|
||||
|
||||
// Explicit session-related phrases — high confidence
|
||||
if (
|
||||
cleaned.includes('session not found') ||
|
||||
cleaned.includes('session does not exist') ||
|
||||
cleaned.includes('invalid session') ||
|
||||
cleaned.includes('session expired') ||
|
||||
cleaned.includes('no such session')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generic "NotFoundError" / "resource not found" are only session errors
|
||||
// when the message also references a session path or session ID.
|
||||
// Without this guard, errors like "ProviderModelNotFoundError" or
|
||||
// "Resource not found: /path/to/config.json" would false-positive.
|
||||
if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) {
|
||||
return cleaned.includes('/session/') || /\bsession\b/.test(cleaned);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from a string.
|
||||
*
|
||||
* The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m").
|
||||
* These escape codes render as garbled text like "[91m[1mError: [0m" in the UI
|
||||
* when passed through as-is. This utility removes them so error messages are
|
||||
* clean and human-readable.
|
||||
*/
|
||||
private static stripAnsiCodes(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a CLI error message for display.
|
||||
*
|
||||
* Strips ANSI escape codes AND removes the redundant "Error: " prefix that
|
||||
* the OpenCode CLI prepends to error messages in its colored stderr output
|
||||
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found").
|
||||
*
|
||||
* Without this, consumers that wrap the message in their own "Error: " prefix
|
||||
* (like AgentService or AgentExecutor) produce garbled double-prefixed output:
|
||||
* "Error: Error: Session not found".
|
||||
*/
|
||||
private static cleanErrorMessage(text: string): string {
|
||||
let cleaned = OpencodeProvider.stripAnsiCodes(text).trim();
|
||||
// Remove leading "Error: " prefix (case-insensitive) if present.
|
||||
// The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m<actual message>
|
||||
// After ANSI stripping this becomes: "Error: <actual message>"
|
||||
cleaned = cleaned.replace(/^Error:\s*/i, '').trim();
|
||||
return cleaned || text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with automatic session resumption fallback.
|
||||
*
|
||||
* When a sdkSessionId is provided, the CLI receives `--session <id>`.
|
||||
* If the session no longer exists on disk the CLI will fail with a
|
||||
* "NotFoundError" / "Resource not found" / "Session not found" error.
|
||||
*
|
||||
* The opencode CLI writes this to **stderr** and exits non-zero.
|
||||
* `spawnJSONLProcess` collects stderr and **yields** it as
|
||||
* `{ type: 'error', error: <stderrText> }` — it is NOT thrown.
|
||||
* After `normalizeEvent`, the error becomes a yielded `ProviderMessage`
|
||||
* with `type: 'error'`. A simple try/catch therefore cannot intercept it.
|
||||
*
|
||||
* This override iterates the parent stream, intercepts yielded error
|
||||
* messages that match the session-not-found pattern, and retries the
|
||||
* entire query WITHOUT the `--session` flag so a fresh session is started.
|
||||
*
|
||||
* Session-not-found retry is ONLY attempted when `sdkSessionId` is set.
|
||||
* Without the `--session` flag the CLI always creates a fresh session, so
|
||||
* retrying without it would be identical to the first attempt and would
|
||||
* fail the same way — producing a confusing "session could not be created"
|
||||
* message for what is actually a different error (model not found, auth
|
||||
* failure, etc.).
|
||||
*
|
||||
* All error messages (session or not) are cleaned of ANSI codes and the
|
||||
* CLI's redundant "Error: " prefix before being yielded to consumers.
|
||||
*
|
||||
* After a successful retry, the consumer (AgentService) will receive a new
|
||||
* session_id from the fresh stream events, which it persists to metadata —
|
||||
* replacing the stale sdkSessionId and preventing repeated failures.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// When no sdkSessionId is set, there is nothing to "retry without" — just
|
||||
// stream normally and clean error messages as they pass through.
|
||||
if (!options.sdkSessionId) {
|
||||
for await (const msg of super.executeQuery(options)) {
|
||||
// Clean error messages so consumers don't get ANSI or double "Error:" prefix
|
||||
if (msg.type === 'error' && msg.error && typeof msg.error === 'string') {
|
||||
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
|
||||
}
|
||||
yield msg;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// sdkSessionId IS set — the CLI will receive `--session <id>`.
|
||||
// If that session no longer exists, intercept the error and retry fresh.
|
||||
//
|
||||
// To avoid buffering the entire stream in memory for long-lived sessions,
|
||||
// we only buffer an initial window of messages until we observe a healthy
|
||||
// (non-error) message. Once a healthy message is seen, we flush the buffer
|
||||
// and switch to direct passthrough, while still watching for session errors
|
||||
// via isSessionNotFoundError on any subsequent error messages.
|
||||
const buffered: ProviderMessage[] = [];
|
||||
let sessionError = false;
|
||||
let seenHealthyMessage = false;
|
||||
|
||||
try {
|
||||
for await (const msg of super.executeQuery(options)) {
|
||||
if (msg.type === 'error') {
|
||||
const errorText = msg.error || '';
|
||||
if (OpencodeProvider.isSessionNotFoundError(errorText)) {
|
||||
sessionError = true;
|
||||
opencodeLogger.info(
|
||||
`OpenCode session error detected (session "${options.sdkSessionId}") ` +
|
||||
`— retrying without --session to start fresh`
|
||||
);
|
||||
break; // stop consuming the failed stream
|
||||
}
|
||||
|
||||
// Non-session error — clean it
|
||||
if (msg.error && typeof msg.error === 'string') {
|
||||
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
|
||||
}
|
||||
} else {
|
||||
// A non-error message is a healthy signal — stop buffering after this
|
||||
seenHealthyMessage = true;
|
||||
}
|
||||
|
||||
if (seenHealthyMessage && buffered.length > 0) {
|
||||
// Flush the pre-healthy buffer first, then switch to passthrough
|
||||
for (const bufferedMsg of buffered) {
|
||||
yield bufferedMsg;
|
||||
}
|
||||
buffered.length = 0;
|
||||
}
|
||||
|
||||
if (seenHealthyMessage) {
|
||||
// Passthrough mode — yield directly without buffering
|
||||
yield msg;
|
||||
} else {
|
||||
// Still in initial window — buffer until we see a healthy message
|
||||
buffered.push(msg);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
if (OpencodeProvider.isSessionNotFoundError(errMsg)) {
|
||||
sessionError = true;
|
||||
opencodeLogger.info(
|
||||
`OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` +
|
||||
`— retrying without --session to start fresh`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionError) {
|
||||
// Retry the entire query without the stale session ID.
|
||||
const retryOptions = { ...options, sdkSessionId: undefined };
|
||||
opencodeLogger.info('Retrying OpenCode query without --session flag...');
|
||||
|
||||
// Stream the retry directly to the consumer.
|
||||
// If the retry also fails, it's a genuine error (not session-related)
|
||||
// and should be surfaced as-is rather than masked with a misleading
|
||||
// "session could not be created" message.
|
||||
for await (const retryMsg of super.executeQuery(retryOptions)) {
|
||||
if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') {
|
||||
retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error);
|
||||
}
|
||||
yield retryMsg;
|
||||
}
|
||||
} else if (buffered.length > 0) {
|
||||
// No session error and still have buffered messages (stream ended before
|
||||
// any healthy message was observed) — flush them to the consumer
|
||||
for (const msg of buffered) {
|
||||
yield msg;
|
||||
}
|
||||
}
|
||||
// If seenHealthyMessage is true, all messages have already been yielded
|
||||
// directly in passthrough mode — nothing left to flush.
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw CLI event to ProviderMessage format
|
||||
*
|
||||
* Maps OpenCode event types to the standard ProviderMessage structure:
|
||||
* - text -> type: 'assistant', content with type: 'text'
|
||||
* - step_start -> null (informational, no message needed)
|
||||
* - step_finish with reason 'stop' -> type: 'result', subtype: 'success'
|
||||
* - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success'
|
||||
* - step_finish with reason 'tool-calls' -> null (intermediate step, not final)
|
||||
* - step_finish with error -> type: 'error'
|
||||
* - tool_call -> type: 'assistant', content with type: 'tool_use'
|
||||
* - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format)
|
||||
* - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format)
|
||||
* - tool_result -> type: 'assistant', content with type: 'tool_result'
|
||||
* - error -> type: 'error'
|
||||
*
|
||||
@@ -459,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: finishEvent.part.error,
|
||||
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,15 +709,40 @@ export class OpencodeProvider extends CliProvider {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: 'Step execution failed',
|
||||
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
|
||||
};
|
||||
}
|
||||
|
||||
// Successful completion (reason: 'stop' or 'end_turn')
|
||||
// Intermediate step completion (reason: 'tool-calls') — the agent loop
|
||||
// is continuing because the model requested tool calls. Skip these so
|
||||
// consumers don't mistake them for final results.
|
||||
if (finishEvent.part?.reason === 'tool-calls') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only treat an explicit allowlist of reasons as true success.
|
||||
// Reasons like 'length' (context-window truncation) or 'content-filter'
|
||||
// indicate the model stopped abnormally and must not be surfaced as
|
||||
// successful completions.
|
||||
const SUCCESS_REASONS = new Set(['stop', 'end_turn']);
|
||||
const reason = finishEvent.part?.reason;
|
||||
|
||||
if (reason === undefined || SUCCESS_REASONS.has(reason)) {
|
||||
// Final completion (reason: 'stop', 'end_turn', or unset)
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: finishEvent.sessionID,
|
||||
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-success, non-tool-calls reason (e.g. 'length', 'content-filter')
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
subtype: 'error',
|
||||
session_id: finishEvent.sessionID,
|
||||
error: `Step finished with non-success reason: ${reason}`,
|
||||
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
|
||||
};
|
||||
}
|
||||
@@ -484,8 +750,10 @@ export class OpencodeProvider extends CliProvider {
|
||||
case 'tool_error': {
|
||||
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
|
||||
|
||||
// Extract error message from part.error
|
||||
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
|
||||
// Extract error message from part.error and clean ANSI codes
|
||||
const errorMessage = OpencodeProvider.cleanErrorMessage(
|
||||
toolErrorEvent.part?.error || 'Tool execution failed'
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
@@ -494,6 +762,45 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool.
|
||||
// The event format includes the tool name, call ID, and state with input/output.
|
||||
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
|
||||
case 'tool_use': {
|
||||
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
|
||||
const part = toolUseEvent.part;
|
||||
|
||||
// Generate a tool use ID if not provided
|
||||
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
|
||||
const toolName = part?.tool || part?.name || 'unknown';
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolName,
|
||||
tool_use_id: toolUseId,
|
||||
input: part?.state?.input || part?.args,
|
||||
},
|
||||
];
|
||||
|
||||
// If the tool has already completed (state.status === 'completed'), also emit the result
|
||||
if (part?.state?.status === 'completed' && part?.state?.output) {
|
||||
content.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: part.state.output,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolUseEvent.sessionID,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_call': {
|
||||
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||
|
||||
@@ -560,6 +867,13 @@ export class OpencodeProvider extends CliProvider {
|
||||
errorMessage = errorEvent.part.error;
|
||||
}
|
||||
|
||||
// Clean error messages: strip ANSI escape codes AND the redundant "Error: "
|
||||
// prefix the CLI adds. The OpenCode CLI outputs colored stderr like:
|
||||
// \x1b[91m\x1b[1mError: \x1b[0mSession not found
|
||||
// Without cleaning, consumers that wrap in their own "Error: " prefix
|
||||
// produce "Error: Error: Session not found".
|
||||
errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage);
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.sessionID,
|
||||
@@ -623,9 +937,9 @@ export class OpencodeProvider extends CliProvider {
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'opencode/glm-4.7-free',
|
||||
name: 'GLM 4.7 Free',
|
||||
modelString: 'opencode/glm-4.7-free',
|
||||
id: 'opencode/glm-5-free',
|
||||
name: 'GLM 5 Free',
|
||||
modelString: 'opencode/glm-5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier GLM model',
|
||||
supportsTools: true,
|
||||
@@ -643,19 +957,19 @@ export class OpencodeProvider extends CliProvider {
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/grok-code',
|
||||
name: 'Grok Code (Free)',
|
||||
modelString: 'opencode/grok-code',
|
||||
id: 'opencode/kimi-k2.5-free',
|
||||
name: 'Kimi K2.5 Free',
|
||||
modelString: 'opencode/kimi-k2.5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier Grok model for coding',
|
||||
description: 'OpenCode free tier Kimi model for coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/minimax-m2.1-free',
|
||||
name: 'MiniMax M2.1 Free',
|
||||
modelString: 'opencode/minimax-m2.1-free',
|
||||
id: 'opencode/minimax-m2.5-free',
|
||||
name: 'MiniMax M2.5 Free',
|
||||
modelString: 'opencode/minimax-m2.5-free',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier MiniMax model',
|
||||
supportsTools: true,
|
||||
@@ -777,7 +1091,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
*
|
||||
* OpenCode CLI output format (one model per line):
|
||||
* opencode/big-pickle
|
||||
* opencode/glm-4.7-free
|
||||
* opencode/glm-5-free
|
||||
* anthropic/claude-3-5-haiku-20241022
|
||||
* github-copilot/claude-3.5-sonnet
|
||||
* ...
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ProviderFactory {
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param options Optional settings
|
||||
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
|
||||
* @returns Provider instance for the model
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
|
||||
import { ProviderFactory } from './provider-factory.js';
|
||||
import type {
|
||||
ProviderMessage,
|
||||
ContentBlock,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ClaudeApiProfile,
|
||||
@@ -96,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
|
||||
/**
|
||||
* Default model to use when none specified
|
||||
*/
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
/**
|
||||
* Execute a simple query and return the text result
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createHistoryHandler(agentService: AgentService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getHistory(sessionId);
|
||||
const result = await agentService.getHistory(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Get history failed');
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createQueueListHandler(agentService: AgentService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getQueue(sessionId);
|
||||
const result = await agentService.getQueue(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'List queue failed');
|
||||
|
||||
@@ -53,7 +53,15 @@ export function createSendHandler(agentService: AgentService) {
|
||||
thinkingLevel,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Background error in sendMessage():', error);
|
||||
const errorMsg = (error as Error).message || 'Unknown error';
|
||||
logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg);
|
||||
|
||||
// Emit error via WebSocket so the UI is notified even though
|
||||
// the HTTP response already returned 200. This is critical for
|
||||
// session-not-found errors where sendMessage() throws before it
|
||||
// can emit its own error event (no in-memory session to emit from).
|
||||
agentService.emitSessionError(sessionId, errorMsg);
|
||||
|
||||
logError(error, 'Send message failed (background)');
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
|
||||
import { AgentService } from '../../../services/agent-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
const logger = createLogger('Agent');
|
||||
const _logger = createLogger('Agent');
|
||||
|
||||
export function createStartHandler(agentService: AgentService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void {
|
||||
*/
|
||||
export function logError(error: unknown, context: string): void {
|
||||
logger.error(`❌ ${context}:`);
|
||||
logger.error('Error name:', (error as any)?.name);
|
||||
logger.error('Error name:', (error as Error)?.name);
|
||||
logger.error('Error message:', (error as Error)?.message);
|
||||
logger.error('Error stack:', (error as Error)?.stack);
|
||||
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50;
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
updateTechnologyStack,
|
||||
updateRoadmapPhaseStatus,
|
||||
type ImplementedFeature,
|
||||
type RoadmapPhase,
|
||||
} from '../../lib/xml-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auto Mode routes - HTTP API for autonomous feature implementation
|
||||
*
|
||||
* Uses the AutoModeService for real feature execution with Claude Agent SDK
|
||||
* Uses AutoModeServiceCompat which provides the old interface while
|
||||
* delegating to GlobalAutoModeService and per-project facades.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
@@ -20,8 +21,14 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js';
|
||||
import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
import { createReconcileHandler } from './routes/reconcile.js';
|
||||
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
/**
|
||||
* Create auto-mode routes.
|
||||
*
|
||||
* @param autoModeService - AutoModeServiceCompat instance
|
||||
*/
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
@@ -75,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
validatePathParams('projectPath'),
|
||||
createResumeInterruptedHandler(autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/reconcile',
|
||||
validatePathParams('projectPath'),
|
||||
createReconcileHandler(autoModeService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start analysis in background
|
||||
autoModeService.analyzeProject(projectPath).catch((error) => {
|
||||
logger.error(`[AutoMode] Project analysis error:`, error);
|
||||
});
|
||||
// Kick off analysis in the background; attach a rejection handler so
|
||||
// unhandled-promise warnings don't surface and errors are at least logged.
|
||||
// Synchronous throws (e.g. "not implemented") still propagate here.
|
||||
const analysisPromise = autoModeService.analyzeProject(projectPath);
|
||||
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
|
||||
|
||||
res.json({ success: true, message: 'Project analysis started' });
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
|
||||
@@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
projectPath?: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
if (!featureId) {
|
||||
@@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We no longer check hasPendingApproval here because resolvePlanApproval
|
||||
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
|
||||
// This supports cases where the server restarted while waiting for approval
|
||||
@@ -48,11 +56,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
const result = await autoModeService.resolvePlanApproval(
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
projectPath
|
||||
feedback
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, worktreePath } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createContextExistsHandler(autoModeService: AutoModeService) {
|
||||
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
|
||||
@@ -30,16 +30,12 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
|
||||
// Start follow-up in background
|
||||
// followUpFeature derives workDir from feature.branchName
|
||||
// Default to false to match run-feature/resume-feature behavior.
|
||||
// Worktrees should only be used when explicitly enabled by the user.
|
||||
autoModeService
|
||||
// Default to false to match run-feature/resume-feature behavior.
|
||||
// Worktrees should only be used when explicitly enabled by the user.
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when follow-up completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
53
apps/server/src/routes/auto-mode/routes/reconcile.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reconcile Feature States Handler
|
||||
*
|
||||
* On-demand endpoint to reconcile all feature states for a project.
|
||||
* Resets features stuck in transient states (in_progress, interrupted, pipeline_*)
|
||||
* back to resting states (ready/backlog) and emits events to update the UI.
|
||||
*
|
||||
* This is useful when:
|
||||
* - The UI reconnects after a server restart
|
||||
* - A client detects stale feature states
|
||||
* - An admin wants to force-reset stuck features
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
|
||||
const logger = createLogger('ReconcileFeatures');
|
||||
|
||||
interface ReconcileRequest {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function createReconcileHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { projectPath } = req.body as ReconcileRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ error: 'Project path is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Reconciling feature states for ${projectPath}`);
|
||||
|
||||
try {
|
||||
const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reconciledCount,
|
||||
message:
|
||||
reconciledCount > 0
|
||||
? `Reconciled ${reconciledCount} feature(s)`
|
||||
: 'No features needed reconciliation',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error reconciling feature states:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
|
||||
const logger = createLogger('ResumeInterrupted');
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ResumeInterruptedRequest {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { projectPath } = req.body as ResumeInterruptedRequest;
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createResumeInterruptedHandler(autoModeService: AutoModeService)
|
||||
|
||||
try {
|
||||
await autoModeService.resumeInterruptedFeatures(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Resume check completed',
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check per-worktree capacity before starting
|
||||
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
||||
if (!capacity.hasCapacity) {
|
||||
const worktreeDesc = capacity.branchName
|
||||
? `worktree "${capacity.branchName}"`
|
||||
: 'main worktree';
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
||||
details: {
|
||||
currentAgents: capacity.currentAgents,
|
||||
maxAgents: capacity.maxAgents,
|
||||
branchName: capacity.branchName,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Note: No concurrency limit check here. Manual feature starts always run
|
||||
// immediately and bypass the concurrency limit. Their presence IS counted
|
||||
// by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
|
||||
|
||||
// Start execution in background
|
||||
// executeFeature derives workDir from feature.branchName
|
||||
@@ -50,10 +36,6 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
|
||||
.catch((error) => {
|
||||
logger.error(`Feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when execution completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
/**
|
||||
* Create status handler.
|
||||
*/
|
||||
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
@@ -21,7 +24,8 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
if (projectPath) {
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
|
||||
const projectStatus = await autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
@@ -38,7 +42,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to global status for backward compatibility
|
||||
// Global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId } = req.body as { featureId: string };
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
|
||||
// Claude Code process crash
|
||||
// Claude Code process crash - extract exit code for diagnostics
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
|
||||
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
|
||||
}
|
||||
|
||||
// Claude Code process killed by signal
|
||||
if (rawMessage.includes('Claude Code process terminated by signal')) {
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const signal = signalMatch ? signalMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
|
||||
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
*
|
||||
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
||||
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
||||
*
|
||||
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
|
||||
* process exited unexpectedly") to improve reliability.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||
import type { Feature, BacklogPlanResult } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_PHASE_MODELS,
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
type ThinkingLevel,
|
||||
type SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { getCurrentBranch } from '@automaker/git-utils';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
@@ -27,10 +32,28 @@ import {
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getProviderByModelId,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
/** Maximum number of retry attempts for transient CLI failures */
|
||||
const MAX_RETRIES = 2;
|
||||
/** Delay between retries in milliseconds */
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Check if an error is retryable (transient CLI process failure)
|
||||
*/
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('Claude Code process exited') ||
|
||||
message.includes('Claude Code process terminated by signal')
|
||||
);
|
||||
}
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
/**
|
||||
@@ -84,6 +107,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a valid plan response without fallback behavior.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
|
||||
if (!response || response.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the most reliable response text between streamed assistant chunks
|
||||
* and provider final result payload.
|
||||
*/
|
||||
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
|
||||
const hasAccumulated = accumulatedText.trim().length > 0;
|
||||
const hasProviderResult = providerResultText.trim().length > 0;
|
||||
|
||||
if (!hasProviderResult) {
|
||||
return accumulatedText;
|
||||
}
|
||||
if (!hasAccumulated) {
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
|
||||
const providerParsed = tryParsePlanResponse(providerResultText);
|
||||
|
||||
if (providerParsed && !accumulatedParsed) {
|
||||
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
|
||||
return providerResultText;
|
||||
}
|
||||
if (accumulatedParsed && !providerParsed) {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
if (providerResultText.length > accumulatedText.length) {
|
||||
logger.info('[BacklogPlan] Using provider result (longer content)');
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a backlog modification plan based on user prompt
|
||||
*/
|
||||
@@ -93,11 +163,40 @@ export async function generateBacklogPlan(
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
model?: string
|
||||
model?: string,
|
||||
branchName?: string
|
||||
): Promise<BacklogPlanResult> {
|
||||
try {
|
||||
// Load current features
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Filter features by branch if specified (worktree-scoped backlog)
|
||||
let features: Feature[];
|
||||
if (branchName) {
|
||||
// Determine the primary branch so unassigned features show for the main worktree
|
||||
let primaryBranch: string | null = null;
|
||||
try {
|
||||
primaryBranch = await getCurrentBranch(projectPath);
|
||||
} catch {
|
||||
// If git fails, fall back to 'main' so unassigned features are visible
|
||||
// when branchName matches a common default branch name
|
||||
primaryBranch = 'main';
|
||||
}
|
||||
const isMainBranch = branchName === primaryBranch;
|
||||
|
||||
features = allFeatures.filter((f) => {
|
||||
if (!f.branchName) {
|
||||
// Unassigned features belong to the main/primary worktree
|
||||
return isMainBranch;
|
||||
}
|
||||
return f.branchName === branchName;
|
||||
});
|
||||
logger.info(
|
||||
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
|
||||
);
|
||||
} else {
|
||||
features = allFeatures;
|
||||
}
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
@@ -133,6 +232,35 @@ export async function generateBacklogPlan(
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
credentials = await settingsService?.getCredentials();
|
||||
// Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
effectiveModel,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
if (providerResult.credentials) {
|
||||
credentials = providerResult.credentials;
|
||||
}
|
||||
}
|
||||
// Fallback: use phase settings provider if model lookup found nothing (e.g. model
|
||||
// string format differs from provider's model id, but backlog planning phase has providerId).
|
||||
if (!claudeCompatibleProvider) {
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'backlogPlanningModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
if (phaseResult.provider && phaseResolved.model === effectiveModel) {
|
||||
claudeCompatibleProvider = phaseResult.provider;
|
||||
credentials = phaseResult.credentials ?? credentials;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
@@ -162,17 +290,23 @@ export async function generateBacklogPlan(
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// For Cursor models, we need to combine prompts with explicit instructions
|
||||
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
||||
let finalPrompt = userPrompt;
|
||||
let finalSystemPrompt: string | undefined = systemPrompt;
|
||||
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
|
||||
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
|
||||
|
||||
if (isCursorModel(effectiveModel)) {
|
||||
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
||||
@@ -187,54 +321,145 @@ CRITICAL INSTRUCTIONS:
|
||||
|
||||
${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
} else if (claudeCompatibleProvider) {
|
||||
// Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
|
||||
// the claude_code preset (which is for Claude CLI/subprocess and can break the request).
|
||||
finalSystemPrompt = systemPrompt;
|
||||
} else if (useClaudeCodeSystemPrompt) {
|
||||
// Use claude_code preset for native Claude so the SDK subprocess
|
||||
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
|
||||
finalSystemPrompt = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: systemPrompt,
|
||||
};
|
||||
}
|
||||
// Include settingSources when autoLoadClaudeMd is enabled
|
||||
if (autoLoadClaudeMd) {
|
||||
finalSettingSources = ['user', 'project'];
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
// Execute the query with retry logic for transient CLI failures
|
||||
const queryOptions = {
|
||||
prompt: finalPrompt,
|
||||
model: bareModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [], // No tools needed for this
|
||||
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
|
||||
abortController,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
settingSources: finalSettingSources,
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
};
|
||||
|
||||
let responseText = '';
|
||||
let bestResponseText = ''; // Preserve best response across all retry attempts
|
||||
let recoveredResult: BacklogPlanResult | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
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;
|
||||
if (attempt > 0) {
|
||||
logger.info(
|
||||
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
|
||||
);
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
|
||||
let accumulatedText = '';
|
||||
let providerResultText = '';
|
||||
|
||||
try {
|
||||
const stream = provider.executeQuery(queryOptions);
|
||||
|
||||
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') {
|
||||
accumulatedText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
providerResultText = msg.result;
|
||||
logger.info(
|
||||
'[BacklogPlan] Received result from provider, length:',
|
||||
providerResultText.length
|
||||
);
|
||||
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message (from Cursor provider)
|
||||
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
|
||||
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
|
||||
if (msg.result.length > responseText.length) {
|
||||
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
|
||||
responseText = msg.result;
|
||||
} else {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
|
||||
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// If we got here, the stream completed successfully
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// Preserve the best response text across all attempts so that if a retry
|
||||
// crashes immediately (empty response), we can still recover from an earlier attempt
|
||||
bestResponseText = selectBestResponseText(bestResponseText, responseText);
|
||||
|
||||
// Claude SDK can occasionally exit non-zero after emitting a complete response.
|
||||
// If we already have valid JSON, recover instead of failing the entire planning flow.
|
||||
if (isRetryableError(error)) {
|
||||
const parsed = tryParsePlanResponse(bestResponseText);
|
||||
if (parsed) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
|
||||
);
|
||||
recoveredResult = parsed;
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// On final retryable failure, degrade gracefully if we have text from any attempt.
|
||||
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
|
||||
);
|
||||
recoveredResult = parsePlanResponse(bestResponseText);
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry on transient CLI failures, not on user aborts or other errors
|
||||
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhausted retries, throw the last error
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
const result = recoveredResult ?? parsePlanResponse(responseText);
|
||||
|
||||
await saveBacklogPlan(projectPath, {
|
||||
savedAt: new Date().toISOString(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
@@ -58,6 +58,9 @@ export function createApplyHandler() {
|
||||
if (feature.dependencies?.includes(change.featureId)) {
|
||||
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
|
||||
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
|
||||
// Mutate the in-memory feature object so subsequent deletions use the updated
|
||||
// dependency list and don't reintroduce already-removed dependency IDs.
|
||||
feature.dependencies = newDeps;
|
||||
logger.info(
|
||||
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
|
||||
);
|
||||
|
||||
@@ -17,10 +17,11 @@ 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 {
|
||||
const { projectPath, prompt, model, branchName } = req.body as {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
branchName?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningDetails({
|
||||
projectPath,
|
||||
prompt,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
});
|
||||
// Note: generateBacklogPlan handles its own error event emission
|
||||
// and state cleanup in its finally block, so we only log here
|
||||
generateBacklogPlan(
|
||||
projectPath,
|
||||
prompt,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
branchName
|
||||
).catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
||||
|
||||
if (!rawMessage) return baseResponse;
|
||||
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
if (
|
||||
rawMessage.includes('Claude Code process exited') ||
|
||||
rawMessage.includes('Claude Code process terminated by signal')
|
||||
) {
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const detail = exitCodeMatch
|
||||
? ` (exit code: ${exitCodeMatch[1]})`
|
||||
: signalMatch
|
||||
? ` (signal: ${signalMatch[1]})`
|
||||
: '';
|
||||
|
||||
// Crash/OS-kill signals suggest a process crash, not an auth failure —
|
||||
// omit auth recovery advice and suggest retry/reporting instead.
|
||||
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
|
||||
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
|
||||
|
||||
if (isCrashSignal) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage:
|
||||
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
|
||||
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
import {
|
||||
extractTechnologyStack,
|
||||
extractXmlElements,
|
||||
extractXmlSection,
|
||||
unescapeXml,
|
||||
} from '../../../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function buildProjectContext(projectPath: string): Promise<string | null> {
|
||||
const contextBlocks: string[] = [];
|
||||
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
const projectName = extractXmlSection(specContent, 'project_name');
|
||||
const overview = extractXmlSection(specContent, 'overview');
|
||||
const techStack = extractTechnologyStack(specContent);
|
||||
const coreSection = extractXmlSection(specContent, 'core_capabilities');
|
||||
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (projectName) {
|
||||
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
|
||||
}
|
||||
if (overview) {
|
||||
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
|
||||
}
|
||||
if (techStack.length > 0) {
|
||||
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
|
||||
}
|
||||
if (coreCapabilities.length > 0) {
|
||||
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
|
||||
}
|
||||
|
||||
if (summaryLines.length > 0) {
|
||||
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('No app_spec.txt context available for enhancement', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const featureTitles = features
|
||||
.map((feature) => feature.title || feature.name || feature.id)
|
||||
.filter((title) => Boolean(title));
|
||||
|
||||
if (featureTitles.length > 0) {
|
||||
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
|
||||
contextBlocks.push(
|
||||
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
|
||||
featureTitles.length > 30 ? '\n- ...' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to load existing features for enhancement context', error);
|
||||
}
|
||||
|
||||
if (contextBlocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contextBlocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
|
||||
if (projectContext) {
|
||||
logger.debug('Including project context in enhancement prompt');
|
||||
}
|
||||
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
@@ -146,18 +219,21 @@ export function createEnhanceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the model - use provider resolved model, passed model, or default to sonnet
|
||||
const resolvedModel =
|
||||
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
// Resolve the model for API call.
|
||||
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
|
||||
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
const modelForApi = claudeCompatibleProvider
|
||||
? model
|
||||
: providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
logger.debug(`Using model: ${modelForApi}`);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
const result = await simpleQuery({
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
model: resolvedModel,
|
||||
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||
model: modelForApi,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
@@ -24,7 +24,7 @@ export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter,
|
||||
autoModeService?: AutoModeService
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -33,13 +33,22 @@ export function createFeaturesRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.get(
|
||||
'/list',
|
||||
validatePathParams('projectPath'),
|
||||
createListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post(
|
||||
'/create',
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(featureLoader, events)
|
||||
);
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post(
|
||||
'/update',
|
||||
validatePathParams('projectPath'),
|
||||
createUpdateHandler(featureLoader, events)
|
||||
);
|
||||
router.post(
|
||||
'/bulk-update',
|
||||
validatePathParams('projectPath'),
|
||||
|
||||
@@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
|
||||
@@ -36,7 +36,7 @@ interface ExportRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
||||
export function createExportHandler(_featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createGenerateTitleHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface ConflictInfo {
|
||||
hasConflict: boolean;
|
||||
}
|
||||
|
||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
||||
export function createImportHandler(_featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* POST /list endpoint - List all features for a project
|
||||
* POST/GET /list endpoint - List all features for a project
|
||||
*
|
||||
* projectPath may come from req.body (POST) or req.query (GET fallback).
|
||||
*
|
||||
* Also performs orphan detection when a project is loaded to identify
|
||||
* features whose branches no longer exist. This runs on every project load/switch.
|
||||
@@ -7,16 +9,29 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
export function createListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
const bodyProjectPath =
|
||||
typeof req.body === 'object' && req.body !== null
|
||||
? (req.body as { projectPath?: unknown }).projectPath
|
||||
: undefined;
|
||||
const queryProjectPath = req.query.projectPath;
|
||||
const projectPath =
|
||||
typeof bodyProjectPath === 'string'
|
||||
? bodyProjectPath
|
||||
: typeof queryProjectPath === 'string'
|
||||
? queryProjectPath
|
||||
: undefined;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -30,18 +45,23 @@ export function createListHandler(featureLoader: FeatureLoader, autoModeService?
|
||||
// We don't await this to keep the list response fast
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
autoModeService
|
||||
.detectOrphanedFeatures(projectPath)
|
||||
.then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, features });
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -13,7 +14,7 @@ const logger = createLogger('features/update');
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
@@ -40,23 +41,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
@@ -71,8 +55,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
// Emit completion event and sync to app_spec.txt when status transitions to verified/completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
events?.emit('feature:completed', {
|
||||
featureId,
|
||||
featureName: updated.title,
|
||||
projectPath,
|
||||
passes: true,
|
||||
message:
|
||||
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
|
||||
executionMode: 'manual',
|
||||
});
|
||||
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
|
||||
@@ -19,6 +19,10 @@ import { createBrowseHandler } from './routes/browse.js';
|
||||
import { createImageHandler } from './routes/image.js';
|
||||
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
||||
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
||||
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
|
||||
import { createCopyHandler } from './routes/copy.js';
|
||||
import { createMoveHandler } from './routes/move.js';
|
||||
import { createDownloadHandler } from './routes/download.js';
|
||||
|
||||
export function createFsRoutes(_events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
@@ -37,6 +41,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
||||
router.get('/image', createImageHandler());
|
||||
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
||||
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
||||
router.post('/browse-project-files', createBrowseProjectFilesHandler());
|
||||
router.post('/copy', createCopyHandler());
|
||||
router.post('/move', createMoveHandler());
|
||||
router.post('/download', createDownloadHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
191
apps/server/src/routes/fs/routes/browse-project-files.ts
Normal file
191
apps/server/src/routes/fs/routes/browse-project-files.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* POST /browse-project-files endpoint - Browse files and directories within a project
|
||||
*
|
||||
* Unlike /browse which only lists directories (for project folder selection),
|
||||
* this endpoint lists both files and directories relative to a project root.
|
||||
* Used by the file selector for "Copy files to worktree" settings.
|
||||
*
|
||||
* Features:
|
||||
* - Lists both files and directories
|
||||
* - Hides .git, .worktrees, node_modules, and other build artifacts
|
||||
* - Returns entries relative to the project root
|
||||
* - Supports navigating into subdirectories
|
||||
* - Security: prevents path traversal outside project root
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
// Directories to hide from the listing (build artifacts, caches, etc.)
|
||||
const HIDDEN_DIRECTORIES = new Set([
|
||||
'.git',
|
||||
'.worktrees',
|
||||
'node_modules',
|
||||
'.automaker',
|
||||
'__pycache__',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.vercel',
|
||||
'.output',
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.tmp',
|
||||
'tmp',
|
||||
'.venv',
|
||||
'venv',
|
||||
'target',
|
||||
'vendor',
|
||||
'.gradle',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
]);
|
||||
|
||||
interface ProjectFileEntry {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
export function createBrowseProjectFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, relativePath } = req.body as {
|
||||
projectPath: string;
|
||||
relativePath?: string; // Relative path within the project to browse (empty = project root)
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
|
||||
// Determine the target directory to browse
|
||||
let targetPath = resolvedProjectPath;
|
||||
let currentRelativePath = '';
|
||||
|
||||
if (relativePath) {
|
||||
// Security: normalize and validate the relative path
|
||||
const normalized = path.normalize(relativePath);
|
||||
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid relative path - must be within the project directory',
|
||||
});
|
||||
return;
|
||||
}
|
||||
targetPath = path.join(resolvedProjectPath, normalized);
|
||||
currentRelativePath = normalized;
|
||||
|
||||
// Double-check the resolved path is within the project
|
||||
// Use a separator-terminated prefix to prevent matching sibling dirs
|
||||
// that share the same prefix (e.g. /projects/foo vs /projects/foobar).
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
const projectPrefix = resolvedProjectPath.endsWith(path.sep)
|
||||
? resolvedProjectPath
|
||||
: resolvedProjectPath + path.sep;
|
||||
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Path traversal detected',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine parent relative path
|
||||
let parentRelativePath: string | null = null;
|
||||
if (currentRelativePath) {
|
||||
const parent = path.dirname(currentRelativePath);
|
||||
parentRelativePath = parent === '.' ? '' : parent;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await secureFs.stat(targetPath);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
res.status(400).json({ success: false, error: 'Path is not a directory' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory contents
|
||||
const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true });
|
||||
|
||||
// Filter and map entries
|
||||
const entries: ProjectFileEntry[] = dirEntries
|
||||
.filter((entry) => {
|
||||
// Skip hidden directories (build artifacts, etc.)
|
||||
if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
// Skip entries starting with . (hidden files) except common config files
|
||||
// We keep hidden files visible since users often need .env, .eslintrc, etc.
|
||||
return true;
|
||||
})
|
||||
.map((entry) => {
|
||||
const entryRelativePath = currentRelativePath
|
||||
? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
|
||||
: entry.name;
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
relativePath: entryRelativePath,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isFile: entry.isFile(),
|
||||
};
|
||||
})
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
currentRelativePath,
|
||||
parentRelativePath,
|
||||
entries,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
|
||||
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
|
||||
|
||||
if (isPermissionError) {
|
||||
res.json({
|
||||
success: true,
|
||||
currentRelativePath,
|
||||
parentRelativePath,
|
||||
entries: [],
|
||||
warning: 'Permission denied - unable to read this directory',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Browse project files failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* POST /copy endpoint - Copy file or directory to a new location
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Recursively copy a directory and its contents
|
||||
*/
|
||||
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
|
||||
await mkdirSafe(dest);
|
||||
const entries = await secureFs.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectoryRecursive(srcPath, destPath);
|
||||
} else {
|
||||
await secureFs.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCopyHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
overwrite?: boolean;
|
||||
};
|
||||
|
||||
if (!sourcePath || !destinationPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent copying a folder into itself or its own descendant (infinite recursion)
|
||||
const resolvedSrc = path.resolve(sourcePath);
|
||||
const resolvedDest = path.resolve(destinationPath);
|
||||
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot copy a folder into itself or one of its own descendants',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
try {
|
||||
await secureFs.stat(destinationPath);
|
||||
// Destination exists
|
||||
if (!overwrite) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Destination already exists',
|
||||
exists: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If overwrite is true, remove the existing destination first to avoid merging
|
||||
await secureFs.rm(destinationPath, { recursive: true });
|
||||
} catch {
|
||||
// Destination doesn't exist - good to proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||
|
||||
// Check if source is a directory
|
||||
const stats = await secureFs.stat(sourcePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
await copyDirectoryRecursive(sourcePath, destinationPath);
|
||||
} else {
|
||||
await secureFs.copyFile(sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Copy file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* POST /download endpoint - Download a file, or GET /download for streaming
|
||||
* For folders, creates a zip archive on the fly
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createReadStream } from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Get total size of a directory recursively
|
||||
*/
|
||||
async function getDirectorySize(dirPath: string): Promise<number> {
|
||||
let totalSize = 0;
|
||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
totalSize += await getDirectorySize(entryPath);
|
||||
} else {
|
||||
const stats = await secureFs.stat(entryPath);
|
||||
totalSize += Number(stats.size);
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
export function createDownloadHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await secureFs.stat(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// For directories, create a zip archive
|
||||
const dirSize = await getDirectorySize(filePath);
|
||||
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
|
||||
|
||||
if (dirSize > MAX_DIR_SIZE) {
|
||||
res.status(413).json({
|
||||
success: false,
|
||||
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
|
||||
size: dirSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary zip file
|
||||
const zipFileName = `${fileName}.zip`;
|
||||
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
|
||||
|
||||
try {
|
||||
// Use system zip command (available on macOS and Linux)
|
||||
// Use execFile to avoid shell injection via user-provided paths
|
||||
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
|
||||
cwd: path.dirname(filePath),
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const zipStats = await secureFs.stat(tmpZipPath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||
res.setHeader('Content-Length', zipStats.size.toString());
|
||||
res.setHeader('X-Directory-Size', dirSize.toString());
|
||||
|
||||
const stream = createReadStream(tmpZipPath);
|
||||
stream.pipe(res);
|
||||
|
||||
stream.on('end', async () => {
|
||||
// Cleanup temp file
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', async (err) => {
|
||||
logError(err, 'Download stream error');
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||
}
|
||||
});
|
||||
} catch (zipError) {
|
||||
// Cleanup on zip failure
|
||||
try {
|
||||
await secureFs.rm(tmpZipPath);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
throw zipError;
|
||||
}
|
||||
} else {
|
||||
// For individual files, stream directly
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.setHeader('Content-Length', stats.size.toString());
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
stream.pipe(res);
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logError(err, 'Download stream error');
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Download failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -35,9 +35,9 @@ export function createMkdirHandler() {
|
||||
error: 'Path exists and is not a directory',
|
||||
});
|
||||
return;
|
||||
} catch (statError: any) {
|
||||
} catch (statError: unknown) {
|
||||
// ENOENT means path doesn't exist - we should create it
|
||||
if (statError.code !== 'ENOENT') {
|
||||
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Some other error (could be ELOOP in parent path)
|
||||
throw statError;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function createMkdirHandler() {
|
||||
await secureFs.mkdir(resolvedPath, { recursive: true });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Path not allowed - return 403 Forbidden
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
@@ -55,7 +55,7 @@ export function createMkdirHandler() {
|
||||
}
|
||||
|
||||
// Handle ELOOP specifically
|
||||
if (error.code === 'ELOOP') {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
logError(error, 'Create directory failed - symlink loop detected');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* POST /move endpoint - Move (rename) file or directory to a new location
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { mkdirSafe } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createMoveHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
overwrite?: boolean;
|
||||
};
|
||||
|
||||
if (!sourcePath || !destinationPath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent moving to same location or into its own descendant
|
||||
const resolvedSrc = path.resolve(sourcePath);
|
||||
const resolvedDest = path.resolve(destinationPath);
|
||||
if (resolvedDest === resolvedSrc) {
|
||||
// No-op: source and destination are the same
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot move a folder into one of its own descendants',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
try {
|
||||
await secureFs.stat(destinationPath);
|
||||
// Destination exists
|
||||
if (!overwrite) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Destination already exists',
|
||||
exists: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If overwrite is true, remove the existing destination first
|
||||
await secureFs.rm(destinationPath, { recursive: true });
|
||||
} catch {
|
||||
// Destination doesn't exist - good to proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||
|
||||
// Use rename for the move operation
|
||||
await secureFs.rename(sourcePath, destinationPath);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PathNotAllowedError) {
|
||||
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Move file failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createResolveDirectoryHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { directoryName, sampleFiles, fileCount } = req.body as {
|
||||
const {
|
||||
directoryName,
|
||||
sampleFiles,
|
||||
fileCount: _fileCount,
|
||||
} = req.body as {
|
||||
directoryName: string;
|
||||
sampleFiles?: string[];
|
||||
fileCount?: number;
|
||||
|
||||
@@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform';
|
||||
export function createSaveBoardBackgroundHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { data, filename, mimeType, projectPath } = req.body as {
|
||||
const { data, filename, projectPath } = req.body as {
|
||||
data: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
import { sanitizeFilename } from '@automaker/utils';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { data, filename, mimeType, projectPath } = req.body as {
|
||||
const { data, filename, projectPath } = req.body as {
|
||||
data: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createSaveImageHandler() {
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const baseName = path.basename(filename, ext);
|
||||
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
|
||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||
const filePath = path.join(imagesDir, uniqueFilename);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createValidatePathHandler() {
|
||||
|
||||
@@ -24,7 +24,9 @@ export function createWriteHandler() {
|
||||
|
||||
// Ensure parent directory exists (symlink-safe)
|
||||
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
||||
await secureFs.writeFile(filePath, content, 'utf-8');
|
||||
// Default content to empty string if undefined/null to prevent writing
|
||||
// "undefined" as literal text (e.g. when content field is missing from request)
|
||||
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
66
apps/server/src/routes/gemini/index.ts
Normal file
66
apps/server/src/routes/gemini/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { GeminiProvider } from '../../providers/gemini-provider.js';
|
||||
import { GeminiUsageService } from '../../services/gemini-usage-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
|
||||
const logger = createLogger('Gemini');
|
||||
|
||||
export function createGeminiRoutes(
|
||||
usageService: GeminiUsageService,
|
||||
_events: EventEmitter
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage/quota data from Google Cloud API
|
||||
router.get('/usage', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const usageData = await usageService.fetchUsageData();
|
||||
|
||||
res.json(usageData);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error fetching Gemini usage:', error);
|
||||
|
||||
// Return error in a format the UI expects
|
||||
res.status(200).json({
|
||||
authenticated: false,
|
||||
authMethod: 'none',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: `Failed to fetch Gemini usage: ${message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check if Gemini is available
|
||||
router.get('/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = new GeminiProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
// Derive authMethod from typed InstallationStatus fields
|
||||
const authMethod = status.authenticated
|
||||
? status.hasApiKey
|
||||
? 'api_key'
|
||||
: 'cli_login'
|
||||
: 'none';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
authenticated: status.authenticated || false,
|
||||
authMethod,
|
||||
hasCredentialsFile: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -6,12 +6,22 @@ import { Router } from 'express';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createDiffsHandler } from './routes/diffs.js';
|
||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import { createDetailsHandler } from './routes/details.js';
|
||||
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
|
||||
|
||||
export function createGitRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||
router.post(
|
||||
'/stage-files',
|
||||
validatePathParams('projectPath', 'files[]'),
|
||||
createStageFilesHandler()
|
||||
);
|
||||
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
|
||||
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
248
apps/server/src/routes/git/routes/details.ts
Normal file
248
apps/server/src/routes/git/routes/details.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* POST /details endpoint - Get detailed git info for a file or project
|
||||
* Returns branch, last commit info, diff stats, and conflict status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface GitFileDetails {
|
||||
branch: string;
|
||||
lastCommitHash: string;
|
||||
lastCommitMessage: string;
|
||||
lastCommitAuthor: string;
|
||||
lastCommitTimestamp: string;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export function createDetailsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
if (!filePath) {
|
||||
// Project-level details - just return branch info
|
||||
res.json({
|
||||
success: true,
|
||||
details: { branch },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get last commit info for this file
|
||||
let lastCommitHash = '';
|
||||
let lastCommitMessage = '';
|
||||
let lastCommitAuthor = '';
|
||||
let lastCommitTimestamp = '';
|
||||
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (logOutput.trim()) {
|
||||
const parts = logOutput.trim().split('|');
|
||||
lastCommitHash = parts[0] || '';
|
||||
lastCommitMessage = parts[1] || '';
|
||||
lastCommitAuthor = parts[2] || '';
|
||||
lastCommitTimestamp = parts[3] || '';
|
||||
}
|
||||
} catch {
|
||||
// File may not have any commits yet
|
||||
}
|
||||
|
||||
// Get diff stats (lines added/removed)
|
||||
let linesAdded = 0;
|
||||
let linesRemoved = 0;
|
||||
|
||||
try {
|
||||
// Check if file is untracked first
|
||||
const { stdout: statusLine } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusLine.trim().startsWith('??')) {
|
||||
// Untracked file - count all lines as added using Node.js instead of shell
|
||||
try {
|
||||
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
|
||||
const lines = fileContent.split('\n');
|
||||
// Don't count trailing empty line from final newline
|
||||
linesAdded =
|
||||
lines.length > 0 && lines[lines.length - 1] === ''
|
||||
? lines.length - 1
|
||||
: lines.length;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
const { stdout: diffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', 'HEAD', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (diffStatRaw.trim()) {
|
||||
const parts = diffStatRaw.trim().split('\t');
|
||||
linesAdded = parseInt(parts[0], 10) || 0;
|
||||
linesRemoved = parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
|
||||
// Also check staged diff stats
|
||||
const { stdout: stagedDiffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', '--cached', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (stagedDiffStatRaw.trim()) {
|
||||
const parts = stagedDiffStatRaw.trim().split('\t');
|
||||
linesAdded += parseInt(parts[0], 10) || 0;
|
||||
linesRemoved += parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Diff might not be available
|
||||
}
|
||||
|
||||
// Get conflict and staging status
|
||||
let isConflicted = false;
|
||||
let isStaged = false;
|
||||
let isUnstaged = false;
|
||||
let statusLabel = '';
|
||||
|
||||
try {
|
||||
const { stdout: statusOutput } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusOutput.trim()) {
|
||||
const indexStatus = statusOutput[0];
|
||||
const workTreeStatus = statusOutput[1];
|
||||
|
||||
// Check for conflicts (both modified, unmerged states)
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
isConflicted = true;
|
||||
statusLabel = 'Conflicted';
|
||||
} else {
|
||||
// Staged changes (index has a status)
|
||||
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||
isStaged = true;
|
||||
}
|
||||
// Unstaged changes (work tree has a status)
|
||||
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||
isUnstaged = true;
|
||||
}
|
||||
|
||||
// Build status label
|
||||
if (isStaged && isUnstaged) {
|
||||
statusLabel = 'Staged + Modified';
|
||||
} else if (isStaged) {
|
||||
statusLabel = 'Staged';
|
||||
} else {
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
statusLabel = 'Modified';
|
||||
break;
|
||||
case 'A':
|
||||
statusLabel = 'Added';
|
||||
break;
|
||||
case 'D':
|
||||
statusLabel = 'Deleted';
|
||||
break;
|
||||
case 'R':
|
||||
statusLabel = 'Renamed';
|
||||
break;
|
||||
case 'C':
|
||||
statusLabel = 'Copied';
|
||||
break;
|
||||
case '?':
|
||||
statusLabel = 'Untracked';
|
||||
break;
|
||||
default:
|
||||
statusLabel = statusChar || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Status might not be available
|
||||
}
|
||||
|
||||
const details: GitFileDetails = {
|
||||
branch,
|
||||
lastCommitHash,
|
||||
lastCommitMessage,
|
||||
lastCommitAuthor,
|
||||
lastCommitTimestamp,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
statusLabel,
|
||||
};
|
||||
|
||||
res.json({ success: true, details });
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git details failed');
|
||||
res.json({
|
||||
success: true,
|
||||
details: {
|
||||
branch: '',
|
||||
lastCommitHash: '',
|
||||
lastCommitMessage: '',
|
||||
lastCommitAuthor: '',
|
||||
lastCommitTimestamp: '',
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isConflicted: false,
|
||||
isStaged: false,
|
||||
isUnstaged: false,
|
||||
statusLabel: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get git details failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function createDiffsHandler() {
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.hasChanges,
|
||||
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||
});
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git diff failed');
|
||||
|
||||
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
|
||||
* Returns per-file status with lines added/removed and staged/unstaged differentiation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface EnhancedFileStatus {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
workTreeStatus: string;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
|
||||
// Check for conflicts
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
return 'Conflicted';
|
||||
}
|
||||
|
||||
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
if (hasStaged && hasUnstaged) return 'Staged + Modified';
|
||||
if (hasStaged) return 'Staged';
|
||||
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
default:
|
||||
return statusChar || '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnhancedStatusHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
// Get porcelain status for all files
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
// Get diff numstat for working tree changes
|
||||
let workTreeStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
workTreeStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Get diff numstat for staged changes
|
||||
let stagedStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
stagedStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Parse status and build enhanced file list
|
||||
const files: EnhancedFileStatus[] = [];
|
||||
|
||||
for (const line of statusOutput.split('\n').filter(Boolean)) {
|
||||
if (line.length < 4) continue;
|
||||
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const filePath = line.substring(3).trim();
|
||||
|
||||
// Handle renamed files (format: "R old -> new")
|
||||
const actualPath = filePath.includes(' -> ')
|
||||
? filePath.split(' -> ')[1].trim()
|
||||
: filePath;
|
||||
|
||||
const isConflicted =
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D');
|
||||
|
||||
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
// Combine diff stats from both working tree and staged
|
||||
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
|
||||
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
|
||||
|
||||
files.push({
|
||||
path: actualPath,
|
||||
indexStatus,
|
||||
workTreeStatus,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
linesAdded: wtStats.added + stStats.added,
|
||||
linesRemoved: wtStats.removed + stStats.removed,
|
||||
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
branch,
|
||||
files,
|
||||
});
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git enhanced status failed');
|
||||
res.json({ success: true, branch: '', files: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get enhanced status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
67
apps/server/src/routes/git/routes/stage-files.ts
Normal file
67
apps/server/src/routes/git/routes/stage-files.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* POST /stage-files endpoint - Stage or unstage files in the main project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
|
||||
|
||||
export function createStageFilesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, files, operation } = req.body as {
|
||||
projectPath: string;
|
||||
files: string[];
|
||||
operation: 'stage' | 'unstage';
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'files array required and must not be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (typeof file !== 'string' || file.trim() === '') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Each element of files must be a non-empty string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (operation !== 'stage' && operation !== 'unstage') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'operation must be "stage" or "unstage"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await stageFiles(projectPath, files, operation);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StageFilesValidationError) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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 { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
|
||||
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
|
||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||
import {
|
||||
createValidationStatusHandler,
|
||||
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
|
||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||
router.post(
|
||||
'/pr-review-comments',
|
||||
validatePathParams('projectPath'),
|
||||
createListPRReviewCommentsHandler()
|
||||
);
|
||||
router.post(
|
||||
'/resolve-pr-comment',
|
||||
validatePathParams('projectPath'),
|
||||
createResolvePRCommentHandler()
|
||||
);
|
||||
router.post(
|
||||
'/validate-issue',
|
||||
validatePathParams('projectPath'),
|
||||
|
||||
@@ -1,38 +1,14 @@
|
||||
/**
|
||||
* Common utilities for GitHub routes
|
||||
*
|
||||
* Re-exports shared utilities from lib/exec-utils so route consumers
|
||||
* can continue importing from this module unchanged.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitHub');
|
||||
|
||||
export const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
export const extendedPath = [
|
||||
process.env.PATH,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
`${process.env.HOME}/.local/bin`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
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(`${context}:`, error);
|
||||
}
|
||||
// Re-export shared utilities from the canonical location
|
||||
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
|
||||
*
|
||||
* Fetches both regular PR comments and inline code review comments
|
||||
* for a specific pull request, providing file path and line context.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
import {
|
||||
fetchPRReviewComments,
|
||||
fetchReviewThreadResolvedStatus,
|
||||
type PRReviewComment,
|
||||
type ListPRReviewCommentsResult,
|
||||
} from '../../../services/pr-review-comments.service.js';
|
||||
|
||||
// Re-export types so existing callers continue to work
|
||||
export type { PRReviewComment, ListPRReviewCommentsResult };
|
||||
// Re-export service functions so existing callers continue to work
|
||||
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
|
||||
|
||||
interface ListPRReviewCommentsRequest {
|
||||
projectPath: string;
|
||||
prNumber: number;
|
||||
}
|
||||
|
||||
export function createListPRReviewCommentsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prNumber || typeof prNumber !== 'number') {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'prNumber is required and must be a number' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 comments = await fetchPRReviewComments(
|
||||
projectPath,
|
||||
remoteStatus.owner,
|
||||
remoteStatus.repo,
|
||||
prNumber
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
comments,
|
||||
totalCount: comments.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Fetch PR review comments failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
66
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
|
||||
*
|
||||
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
|
||||
* identified by its GraphQL node ID (threadId).
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
|
||||
|
||||
export interface ResolvePRCommentResult {
|
||||
success: boolean;
|
||||
isResolved?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ResolvePRCommentRequest {
|
||||
projectPath: string;
|
||||
threadId: string;
|
||||
resolve: boolean;
|
||||
}
|
||||
|
||||
export function createResolvePRCommentHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
res.status(400).json({ success: false, error: 'threadId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof resolve !== 'boolean') {
|
||||
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a GitHub repo
|
||||
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||
if (!remoteStatus.hasGitHubRemote) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Project does not have a GitHub remote',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
isResolved: result.isResolved,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Resolve PR comment failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
import { writeValidation } from '../../../lib/validation-storage.js';
|
||||
import { streamingQuery } from '../../../providers/simple-query-service.js';
|
||||
@@ -188,8 +188,12 @@ ${basePrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider resolved model if available, otherwise use original model
|
||||
const effectiveModel = providerResolvedModel || (model as string);
|
||||
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
|
||||
// to the API, NOT the resolved Claude model - otherwise we get "model not found"
|
||||
// For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514')
|
||||
const effectiveModel = claudeCompatibleProvider
|
||||
? (model as string)
|
||||
: providerResolvedModel || resolveModelString(model as string);
|
||||
logger.info(`Using model: ${effectiveModel}`);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user