diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index 0fcd1e4f..262c46dc 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -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 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 917672b5..3674111f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7ce2b82..f4fe01f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.gitignore b/.gitignore index 7d6c7b0e..dc7e2fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit index f61fd35b..4c156c16 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 00000000..902a7d7f --- /dev/null +++ b/.planning/PROJECT.md @@ -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 + + + +- ✓ 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 + + + +- [ ] 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_ diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..9e1265e9 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -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_ diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 00000000..bd573015 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -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 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_ diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 00000000..e035741c --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -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, + newPaths: Array +): Promise { + // 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` for flexible object parameters + +**Return Values:** + +- Explicit return types required for all public functions +- Return structured objects for multiple values +- Use `Promise` for async functions +- Async generators use `AsyncGenerator` 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` + +**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_ diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 00000000..d7cbafa9 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -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_ diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 00000000..4d645865 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -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_ diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 00000000..a98e07c8 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -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_ diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 00000000..4d58a28f --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -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(generator: AsyncGenerator): Promise { + 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_ diff --git a/CLAUDE.md b/CLAUDE.md index 128cd8d7..84dd1fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DEVELOPMENT_WORKFLOW.md b/DEVELOPMENT_WORKFLOW.md deleted file mode 100644 index 0ce198ce..00000000 --- a/DEVELOPMENT_WORKFLOW.md +++ /dev/null @@ -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/` 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 ` - -Override for a single run: - -```bash -./check-sync.sh --rc -``` - -## 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/ --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 - ``` - - If you have local fixes to publish, push **origin + upstream**: - ```bash - git push origin - git push upstream : - ``` - - Always ask the user which push to perform. - - Origin (origin-only sync): - ```bash - git push origin - ``` - - Upstream RC (publish the same fixes when you have local commits): - ```bash - git push upstream : - ``` -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 - ``` -2. **Make changes and commit** - ```bash - git add -A - git commit -m "type: description" - ``` -3. **Merge upstream RC before shipping** - ```bash - git merge upstream/ --no-edit - ``` -4. **Build and/or test** - ```bash - npm run build:packages - npm run build - ``` -5. **Push to origin** - ```bash - git push -u origin - ``` -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 - ``` -- 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/` 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. diff --git a/Dockerfile b/Dockerfile index 03911b45..7d48e15f 100644 --- a/Dockerfile +++ b/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 diff --git a/OPENCODE_CONFIG_CONTENT b/OPENCODE_CONFIG_CONTENT new file mode 100644 index 00000000..9dabfe49 --- /dev/null +++ b/OPENCODE_CONFIG_CONTENT @@ -0,0 +1,2 @@ +{ + "$schema": "https://opencode.ai/config.json",} \ No newline at end of file diff --git a/README.md b/README.md index 5c5a67fe..e13ad62e 100644 --- a/README.md +++ b/README.md @@ -363,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) diff --git a/apps/server/.env.example b/apps/server/.env.example index a73e3443..bad63123 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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 diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs new file mode 100644 index 00000000..008c1f68 --- /dev/null +++ b/apps/server/eslint.config.mjs @@ -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; diff --git a/apps/server/package.json b/apps/server/package.json index c9015aea..75818b18 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2255fdc1..dcd45da8 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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> | 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> | 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)); @@ -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)?.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; } diff --git a/apps/server/src/lib/cli-detection.ts b/apps/server/src/lib/cli-detection.ts index eba4c68a..a7b5b14d 100644 --- a/apps/server/src/lib/cli-detection.ts +++ b/apps/server/src/lib/cli-detection.ts @@ -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 { const config = CLI_CONFIGS[provider]; - const { timeout = 5000, includeWsl = false, wslDistribution } = options; + const { timeout = 5000 } = options; const issues: string[] = []; const cliInfo: CliInfo = { diff --git a/apps/server/src/lib/error-handler.ts b/apps/server/src/lib/error-handler.ts index 770f26a2..d6720098 100644 --- a/apps/server/src/lib/error-handler.ts +++ b/apps/server/src/lib/error-handler.ts @@ -40,7 +40,7 @@ export interface ErrorClassification { suggestedAction?: string; retryable: boolean; provider?: string; - context?: Record; + context?: Record; } export interface ErrorPattern { @@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [ export function classifyError( error: unknown, provider?: string, - context?: Record + context?: Record ): 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; - 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).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 + context?: Record ): { success: false; error: string; @@ -335,7 +336,7 @@ export function logError( error: unknown, provider?: string, operation?: string, - additionalContext?: Record + additionalContext?: Record ): void { const classification = classifyError(error, provider, { operation, diff --git a/apps/server/src/lib/exec-utils.ts b/apps/server/src/lib/exec-utils.ts new file mode 100644 index 00000000..0073f695 --- /dev/null +++ b/apps/server/src/lib/exec-utils.ts @@ -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); +} diff --git a/apps/server/src/lib/git-log-parser.ts b/apps/server/src/lib/git-log-parser.ts new file mode 100644 index 00000000..85b0cb58 --- /dev/null +++ b/apps/server/src/lib/git-log-parser.ts @@ -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 || [], + }; +} diff --git a/apps/server/src/lib/git.ts b/apps/server/src/lib/git.ts new file mode 100644 index 00000000..697d532d --- /dev/null +++ b/apps/server/src/lib/git.ts @@ -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, + abortController?: AbortController +): Promise { + 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 { + 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 { + 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 +): Promise { + 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); + } +} diff --git a/apps/server/src/lib/permission-enforcer.ts b/apps/server/src/lib/permission-enforcer.ts index 003608ee..714f7d40 100644 --- a/apps/server/src/lib/permission-enforcer.ts +++ b/apps/server/src/lib/permission-enforcer.ts @@ -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) { diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index cc1df2f5..7044221e 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -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 { + 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 { } /** - * 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; /** 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, diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 2f930ef3..48c06383 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -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 { 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 { + 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 { + 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. diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index 4742a5b0..aa6e2487 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -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; } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index cfb59093..0923a626 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -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; + let promptPayload: string | AsyncIterable; 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 { // 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', diff --git a/apps/server/src/providers/codex-models.ts b/apps/server/src/providers/codex-models.ts index 141d5355..22839e28 100644 --- a/apps/server/src/providers/codex-models.ts +++ b/apps/server/src/providers/codex-models.ts @@ -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, + }, ]; /** diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 5c200ea5..63d41036 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -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 { 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): 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 { 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 = ''; } } diff --git a/apps/server/src/providers/codex-sdk-client.ts b/apps/server/src/providers/codex-sdk-client.ts index 51f7c0d2..bc885c72 100644 --- a/apps/server/src/providers/codex-sdk-client.ts +++ b/apps/server/src/providers/codex-sdk-client.ts @@ -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(['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 ?? ''; + + 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, diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index 64423047..b76c5cd4 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -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>; +type CopilotSessionOptions = Parameters[0]; +type ResumableCopilotClient = CopilotClient & { + resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise; +}; + // ============================================================================= // 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) { diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index a2e813c0..6c0d98e7 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -31,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, @@ -69,6 +69,7 @@ interface CursorToolHandler { * 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> = { readToolCall: { name: 'Read', @@ -449,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('-'); @@ -556,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, }; } @@ -877,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 = diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index 09f16c16..f9425d90 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -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 { + 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 */ diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index d2fa13d9..8c58da15 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -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', '' for working directory (using opencode's -c flag) * - '--model', '' for model selection (if specified) + * - '--session', '' 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 ` 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 + // After ANSI stripping this becomes: "Error: " + 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 `. + * 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: }` — 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 { + // 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 `. + // 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 * ... diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 1e91760f..a6dff69e 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -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 diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index 85c25235..5ebe4db9 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -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 diff --git a/apps/server/src/routes/agent/routes/history.ts b/apps/server/src/routes/agent/routes/history.ts index 0859a142..e11578d7 100644 --- a/apps/server/src/routes/agent/routes/history.ts +++ b/apps/server/src/routes/agent/routes/history.ts @@ -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'); diff --git a/apps/server/src/routes/agent/routes/queue-list.ts b/apps/server/src/routes/agent/routes/queue-list.ts index 1096c701..7299e871 100644 --- a/apps/server/src/routes/agent/routes/queue-list.ts +++ b/apps/server/src/routes/agent/routes/queue-list.ts @@ -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'); diff --git a/apps/server/src/routes/agent/routes/send.ts b/apps/server/src/routes/agent/routes/send.ts index 15e97f63..4f6e527c 100644 --- a/apps/server/src/routes/agent/routes/send.ts +++ b/apps/server/src/routes/agent/routes/send.ts @@ -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)'); }); diff --git a/apps/server/src/routes/agent/routes/start.ts b/apps/server/src/routes/agent/routes/start.ts index 1023fa38..dd9b7e41 100644 --- a/apps/server/src/routes/agent/routes/start.ts +++ b/apps/server/src/routes/agent/routes/start.ts @@ -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 => { diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index 1a48fc6a..0731a7dd 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -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)); diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 6558256b..93daeb8e 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -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 diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index d1ba139d..53bdc91a 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -29,7 +29,6 @@ import { updateTechnologyStack, updateRoadmapPhaseStatus, type ImplementedFeature, - type RoadmapPhase, } from '../../lib/xml-extractor.js'; import { getNotificationService } from '../../services/notification-service.js'; diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index e587a061..016447d7 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -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; } diff --git a/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/apps/server/src/routes/auto-mode/routes/analyze-project.ts index 77c95e27..cae70c36 100644 --- a/apps/server/src/routes/auto-mode/routes/analyze-project.ts +++ b/apps/server/src/routes/auto-mode/routes/analyze-project.ts @@ -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 => { 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) { diff --git a/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/apps/server/src/routes/auto-mode/routes/approve-plan.ts index c006e506..14673e31 100644 --- a/apps/server/src/routes/auto-mode/routes/approve-plan.ts +++ b/apps/server/src/routes/auto-mode/routes/approve-plan.ts @@ -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 => { 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) { diff --git a/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/apps/server/src/routes/auto-mode/routes/commit-feature.ts index 7db0ae32..16b9000d 100644 --- a/apps/server/src/routes/auto-mode/routes/commit-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -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 => { try { const { projectPath, featureId, worktreePath } = req.body as { diff --git a/apps/server/src/routes/auto-mode/routes/context-exists.ts b/apps/server/src/routes/auto-mode/routes/context-exists.ts index ef028f3f..8c85c2ab 100644 --- a/apps/server/src/routes/auto-mode/routes/context-exists.ts +++ b/apps/server/src/routes/auto-mode/routes/context-exists.ts @@ -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 => { try { const { projectPath, featureId } = req.body as { diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index bd9c480d..312edcde 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -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 => { 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 }); diff --git a/apps/server/src/routes/auto-mode/routes/reconcile.ts b/apps/server/src/routes/auto-mode/routes/reconcile.ts new file mode 100644 index 00000000..96109051 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/reconcile.ts @@ -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 => { + 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', + }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts index 0a5eb54d..d9f5de32 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -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 => { try { const { projectPath, featureId, useWorktrees } = req.body as { diff --git a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts index 36cda2bd..314bc067 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -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 => { 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', diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index 2d53c8e5..a61a4064 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -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 => { 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 }); diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts index 3ace816d..c8cc8bff 100644 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -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 => { try { const { projectPath, branchName, maxConcurrency } = req.body as { diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index 73c77945..765ff73a 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -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 => { 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(); diff --git a/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/apps/server/src/routes/auto-mode/routes/stop-feature.ts index bec9a4aa..2e3e69eb 100644 --- a/apps/server/src/routes/auto-mode/routes/stop-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/stop-feature.ts @@ -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 => { try { const { featureId } = req.body as { featureId: string }; diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts index b3c2fd52..224b0daf 100644 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -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 => { try { const { projectPath, branchName } = req.body as { diff --git a/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/apps/server/src/routes/auto-mode/routes/verify-feature.ts index f8f4f6f7..7c036812 100644 --- a/apps/server/src/routes/auto-mode/routes/verify-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/verify-feature.ts @@ -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 => { try { const { projectPath, featureId } = req.body as { diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index a1797a3f..27993e95 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -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 diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index 2bd3a6a7..4a1dc95a 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -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(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 { 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(), diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 1a238d17..e0fb7122 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -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}` ); diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index cd67d3db..befe96e8 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -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 => { 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) { diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 018a932c..1b645d5d 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -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.`, }; } diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index dbdde007..5dbd7268 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -219,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, projectContext, userPrompt].filter(Boolean).join('\n\n'), - model: resolvedModel, + model: modelForApi, cwd: process.cwd(), // Enhancement doesn't need a specific working directory maxTurns: 1, allowedTools: [], diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 8c7dbb06..a4ea03b4 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -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'), diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d075..c607e72e 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -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 diff --git a/apps/server/src/routes/features/routes/export.ts b/apps/server/src/routes/features/routes/export.ts index c767dda4..28a048b4 100644 --- a/apps/server/src/routes/features/routes/export.ts +++ b/apps/server/src/routes/features/routes/export.ts @@ -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 => { diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 4e5e0dcb..a84680b0 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -34,7 +34,7 @@ export function createGenerateTitleHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description, projectPath } = req.body as GenerateTitleRequestBody; + const { description } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { diff --git a/apps/server/src/routes/features/routes/import.ts b/apps/server/src/routes/features/routes/import.ts index 85fb6d9b..aa8cfce1 100644 --- a/apps/server/src/routes/features/routes/import.ts +++ b/apps/server/src/routes/features/routes/import.ts @@ -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 => { diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts index 40c35966..71d8a04a 100644 --- a/apps/server/src/routes/features/routes/list.ts +++ b/apps/server/src/routes/features/routes/list.ts @@ -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 => { 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 }); diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1..89e2dde0 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -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 => { 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) { diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts index 58732b3a..e8805102 100644 --- a/apps/server/src/routes/fs/index.ts +++ b/apps/server/src/routes/fs/index.ts @@ -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; } diff --git a/apps/server/src/routes/fs/routes/browse-project-files.ts b/apps/server/src/routes/fs/routes/browse-project-files.ts new file mode 100644 index 00000000..50afee0d --- /dev/null +++ b/apps/server/src/routes/fs/routes/browse-project-files.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/copy.ts b/apps/server/src/routes/fs/routes/copy.ts new file mode 100644 index 00000000..c52a546e --- /dev/null +++ b/apps/server/src/routes/fs/routes/copy.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/download.ts b/apps/server/src/routes/fs/routes/download.ts new file mode 100644 index 00000000..3ac44078 --- /dev/null +++ b/apps/server/src/routes/fs/routes/download.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts index 04d0a836..f813abcd 100644 --- a/apps/server/src/routes/fs/routes/mkdir.ts +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -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, diff --git a/apps/server/src/routes/fs/routes/move.ts b/apps/server/src/routes/fs/routes/move.ts new file mode 100644 index 00000000..8979db55 --- /dev/null +++ b/apps/server/src/routes/fs/routes/move.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/resolve-directory.ts b/apps/server/src/routes/fs/routes/resolve-directory.ts index 5e4147db..be5a5b0d 100644 --- a/apps/server/src/routes/fs/routes/resolve-directory.ts +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js'; export function createResolveDirectoryHandler() { return async (req: Request, res: Response): Promise => { try { - const { directoryName, sampleFiles, fileCount } = req.body as { + const { + directoryName, + sampleFiles, + fileCount: _fileCount, + } = req.body as { directoryName: string; sampleFiles?: string[]; fileCount?: number; diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts index a0c2164a..e8b82169 100644 --- a/apps/server/src/routes/fs/routes/save-board-background.ts +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform'; export function createSaveBoardBackgroundHandler() { return async (req: Request, res: Response): Promise => { try { - const { data, filename, mimeType, projectPath } = req.body as { + const { data, filename, projectPath } = req.body as { data: string; filename: string; - mimeType: string; projectPath: string; }; diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts index 695a8ded..4d48661c 100644 --- a/apps/server/src/routes/fs/routes/save-image.ts +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils'; export function createSaveImageHandler() { return async (req: Request, res: Response): Promise => { try { - const { data, filename, mimeType, projectPath } = req.body as { + const { data, filename, projectPath } = req.body as { data: string; filename: string; - mimeType: string; projectPath: string; }; diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 8659eb5a..9405e0c1 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -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() { diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts index ad70cc9e..f5cdce56 100644 --- a/apps/server/src/routes/fs/routes/write.ts +++ b/apps/server/src/routes/fs/routes/write.ts @@ -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) { diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts new file mode 100644 index 00000000..f49ef634 --- /dev/null +++ b/apps/server/src/routes/gemini/index.ts @@ -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; +} diff --git a/apps/server/src/routes/git/index.ts b/apps/server/src/routes/git/index.ts index 5e959ec9..600eb87b 100644 --- a/apps/server/src/routes/git/index.ts +++ b/apps/server/src/routes/git/index.ts @@ -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; } diff --git a/apps/server/src/routes/git/routes/details.ts b/apps/server/src/routes/git/routes/details.ts new file mode 100644 index 00000000..0861b89e --- /dev/null +++ b/apps/server/src/routes/git/routes/details.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/git/routes/diffs.ts b/apps/server/src/routes/git/routes/diffs.ts index ca919dcf..02ce2028 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -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'); diff --git a/apps/server/src/routes/git/routes/enhanced-status.ts b/apps/server/src/routes/git/routes/enhanced-status.ts new file mode 100644 index 00000000..4d7d2e3d --- /dev/null +++ b/apps/server/src/routes/git/routes/enhanced-status.ts @@ -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 => { + 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 = {}; + 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 = {}; + 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) }); + } + }; +} diff --git a/apps/server/src/routes/git/routes/stage-files.ts b/apps/server/src/routes/git/routes/stage-files.ts new file mode 100644 index 00000000..98ca44c1 --- /dev/null +++ b/apps/server/src/routes/git/routes/stage-files.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index dddae96e..1f315c90 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -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'), diff --git a/apps/server/src/routes/github/routes/common.ts b/apps/server/src/routes/github/routes/common.ts index 211be715..52ce2e3d 100644 --- a/apps/server/src/routes/github/routes/common.ts +++ b/apps/server/src/routes/github/routes/common.ts @@ -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'; diff --git a/apps/server/src/routes/github/routes/list-pr-review-comments.ts b/apps/server/src/routes/github/routes/list-pr-review-comments.ts new file mode 100644 index 00000000..ff16f6e9 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-pr-review-comments.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/resolve-pr-comment.ts b/apps/server/src/routes/github/routes/resolve-pr-comment.ts new file mode 100644 index 00000000..39855c04 --- /dev/null +++ b/apps/server/src/routes/github/routes/resolve-pr-comment.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 69a13b83..38220f6d 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -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 diff --git a/apps/server/src/routes/github/routes/validation-endpoints.ts b/apps/server/src/routes/github/routes/validation-endpoints.ts index 21859737..1f3c2316 100644 --- a/apps/server/src/routes/github/routes/validation-endpoints.ts +++ b/apps/server/src/routes/github/routes/validation-endpoints.ts @@ -6,7 +6,6 @@ import type { Request, Response } from 'express'; import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationEvent } from '@automaker/types'; import { - isValidationRunning, getValidationStatus, getRunningValidations, abortValidation, @@ -15,7 +14,6 @@ import { logger, } from './validation-common.js'; import { - readValidation, getAllValidations, getValidationWithFreshness, deleteValidation, diff --git a/apps/server/src/routes/models/routes/providers.ts b/apps/server/src/routes/models/routes/providers.ts index 174a1fac..fa4d2828 100644 --- a/apps/server/src/routes/models/routes/providers.ts +++ b/apps/server/src/routes/models/routes/providers.ts @@ -12,7 +12,7 @@ export function createProvidersHandler() { // Get installation status from all providers const statuses = await ProviderFactory.checkAllProviders(); - const providers: Record = { + const providers: Record> = { anthropic: { available: statuses.claude?.installed || false, hasApiKey: !!process.env.ANTHROPIC_API_KEY, diff --git a/apps/server/src/routes/projects/index.ts b/apps/server/src/routes/projects/index.ts index 24ecef14..ff58167d 100644 --- a/apps/server/src/routes/projects/index.ts +++ b/apps/server/src/routes/projects/index.ts @@ -4,14 +4,14 @@ import { Router } from 'express'; import type { 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 type { SettingsService } from '../../services/settings-service.js'; import type { NotificationService } from '../../services/notification-service.js'; import { createOverviewHandler } from './routes/overview.js'; export function createProjectsRoutes( featureLoader: FeatureLoader, - autoModeService: AutoModeService, + autoModeService: AutoModeServiceCompat, settingsService: SettingsService, notificationService: NotificationService ): Router { diff --git a/apps/server/src/routes/projects/routes/overview.ts b/apps/server/src/routes/projects/routes/overview.ts index e58c9c0c..436e683f 100644 --- a/apps/server/src/routes/projects/routes/overview.ts +++ b/apps/server/src/routes/projects/routes/overview.ts @@ -9,7 +9,11 @@ import type { Request, Response } from 'express'; import type { FeatureLoader } from '../../../services/feature-loader.js'; -import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import type { + AutoModeServiceCompat, + RunningAgentInfo, + ProjectAutoModeStatus, +} from '../../../services/auto-mode/index.js'; import type { SettingsService } from '../../../services/settings-service.js'; import type { NotificationService } from '../../../services/notification-service.js'; import type { @@ -147,7 +151,7 @@ function getLastActivityAt(features: Feature[]): string | undefined { export function createOverviewHandler( featureLoader: FeatureLoader, - autoModeService: AutoModeService, + autoModeService: AutoModeServiceCompat, settingsService: SettingsService, notificationService: NotificationService ) { @@ -158,7 +162,7 @@ export function createOverviewHandler( const projectRefs: ProjectRef[] = settings.projects || []; // Get all running agents once to count live running features per project - const allRunningAgents = await autoModeService.getRunningAgents(); + const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents(); // Collect project statuses in parallel const projectStatusPromises = projectRefs.map(async (projectRef): Promise => { @@ -169,7 +173,10 @@ export function createOverviewHandler( const totalFeatures = features.length; // Get auto-mode status for this project (main worktree, branchName = null) - const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null); + const autoModeStatus: ProjectAutoModeStatus = await autoModeService.getStatusForProject( + projectRef.path, + null + ); const isAutoModeRunning = autoModeStatus.isAutoLoopRunning; // Count live running features for this project (across all branches) diff --git a/apps/server/src/routes/running-agents/index.ts b/apps/server/src/routes/running-agents/index.ts index a1dbffcd..b94e54f3 100644 --- a/apps/server/src/routes/running-agents/index.ts +++ b/apps/server/src/routes/running-agents/index.ts @@ -3,10 +3,10 @@ */ import { Router } from 'express'; -import type { AutoModeService } from '../../services/auto-mode-service.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; import { createIndexHandler } from './routes/index.js'; -export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { +export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router { const router = Router(); router.get('/', createIndexHandler(autoModeService)); diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 1eeb4ae6..c18be55b 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -3,16 +3,17 @@ */ 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 { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; import { getAllRunningGenerations } from '../../app-spec/common.js'; import path from 'path'; import { getErrorMessage, logError } from '../common.js'; -export function createIndexHandler(autoModeService: AutoModeService) { +export function createIndexHandler(autoModeService: AutoModeServiceCompat) { return async (_req: Request, res: Response): Promise => { try { const runningAgents = [...(await autoModeService.getRunningAgents())]; + const backlogPlanStatus = getBacklogPlanStatus(); const backlogPlanDetails = getRunningDetails(); diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index 817b5c1d..2bc1c2fa 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -46,16 +46,14 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { } // Minimal debug logging to help diagnose accidental wipes. - const projectsLen = Array.isArray((updates as any).projects) - ? (updates as any).projects.length - : undefined; - const trashedLen = Array.isArray((updates as any).trashedProjects) - ? (updates as any).trashedProjects.length + const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined; + const trashedLen = Array.isArray(updates.trashedProjects) + ? updates.trashedProjects.length : undefined; logger.info( `[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${ - (updates as any).theme ?? 'n/a' - }, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}` + updates.theme ?? 'n/a' + }, localStorageMigrated=${updates.localStorageMigrated ?? 'n/a'}` ); // Get old settings to detect theme changes diff --git a/apps/server/src/routes/setup/routes/auth-claude.ts b/apps/server/src/routes/setup/routes/auth-claude.ts index 97a170f4..9eac0989 100644 --- a/apps/server/src/routes/setup/routes/auth-claude.ts +++ b/apps/server/src/routes/setup/routes/auth-claude.ts @@ -4,13 +4,9 @@ import type { Request, Response } from 'express'; import { getErrorMessage, logError } from '../common.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; -const execAsync = promisify(exec); - export function createAuthClaudeHandler() { return async (_req: Request, res: Response): Promise => { try { diff --git a/apps/server/src/routes/setup/routes/auth-opencode.ts b/apps/server/src/routes/setup/routes/auth-opencode.ts index 7d7f35e2..dce314bf 100644 --- a/apps/server/src/routes/setup/routes/auth-opencode.ts +++ b/apps/server/src/routes/setup/routes/auth-opencode.ts @@ -4,13 +4,9 @@ import type { Request, Response } from 'express'; import { logError, getErrorMessage } from '../common.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import * as fs from 'fs'; import * as path from 'path'; -const execAsync = promisify(exec); - export function createAuthOpencodeHandler() { return async (_req: Request, res: Response): Promise => { try { diff --git a/apps/server/src/routes/setup/routes/copilot-models.ts b/apps/server/src/routes/setup/routes/copilot-models.ts index 5a3da128..08b9eda9 100644 --- a/apps/server/src/routes/setup/routes/copilot-models.ts +++ b/apps/server/src/routes/setup/routes/copilot-models.ts @@ -10,9 +10,6 @@ import type { Request, Response } from 'express'; import { CopilotProvider } from '../../../providers/copilot-provider.js'; import { getErrorMessage, logError } from '../common.js'; import type { ModelDefinition } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('CopilotModelsRoute'); // Singleton provider instance for caching let providerInstance: CopilotProvider | null = null; diff --git a/apps/server/src/routes/setup/routes/opencode-models.ts b/apps/server/src/routes/setup/routes/opencode-models.ts index a3b2b7be..e7909bf9 100644 --- a/apps/server/src/routes/setup/routes/opencode-models.ts +++ b/apps/server/src/routes/setup/routes/opencode-models.ts @@ -14,9 +14,6 @@ import { } from '../../../providers/opencode-provider.js'; import { getErrorMessage, logError } from '../common.js'; import type { ModelDefinition } from '@automaker/types'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('OpenCodeModelsRoute'); // Singleton provider instance for caching let providerInstance: OpencodeProvider | null = null; diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index df04d462..18a40bf8 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -6,6 +6,7 @@ import type { Request, Response } from 'express'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; +import { getClaudeAuthIndicators } from '@automaker/platform'; import { getApiKey } from '../common.js'; import { createSecureAuthEnv, @@ -109,6 +110,7 @@ export function createVerifyClaudeAuthHandler() { let authenticated = false; let errorMessage = ''; let receivedAnyContent = false; + let cleanupEnv: (() => void) | undefined; // Create secure auth session const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -150,13 +152,13 @@ export function createVerifyClaudeAuthHandler() { AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic'); // Create temporary environment override for SDK call - const cleanupEnv = createTempEnvOverride(authEnv); + cleanupEnv = createTempEnvOverride(authEnv); // Run a minimal query to verify authentication const stream = query({ prompt: "Reply with only the word 'ok'", options: { - model: 'claude-sonnet-4-20250514', + model: 'claude-sonnet-4-6', maxTurns: 1, allowedTools: [], abortController, @@ -193,8 +195,10 @@ export function createVerifyClaudeAuthHandler() { } // Check specifically for assistant messages with text content - if (msg.type === 'assistant' && (msg as any).message?.content) { - const content = (msg as any).message.content; + const msgRecord = msg as Record; + const msgMessage = msgRecord.message as Record | undefined; + if (msg.type === 'assistant' && msgMessage?.content) { + const content = msgMessage.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === 'text' && block.text) { @@ -310,6 +314,8 @@ export function createVerifyClaudeAuthHandler() { } } finally { clearTimeout(timeoutId); + // Restore process.env to its original state + cleanupEnv?.(); // Clean up the auth session AuthSessionManager.destroySession(sessionId); } @@ -320,9 +326,28 @@ export function createVerifyClaudeAuthHandler() { authMethod, }); + // Determine specific auth type for success messages + const effectiveAuthMethod = authMethod ?? 'api_key'; + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (effectiveAuthMethod === 'api_key') { + authType = 'api_key'; + } else if (effectiveAuthMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + try { + const indicators = await getClaudeAuthIndicators(); + authType = indicators.credentials?.hasOAuthToken ? 'oauth' : 'cli'; + } catch { + // Fall back to generic CLI if credential check fails + authType = 'cli'; + } + } + } + res.json({ success: true, authenticated, + authType, error: errorMessage || undefined, }); } catch (error) { diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 6121e345..5e8b6b32 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -5,7 +5,6 @@ import { randomBytes } from 'crypto'; import { createLogger } from '@automaker/utils'; import type { Request, Response, NextFunction } from 'express'; -import { getTerminalService } from '../../services/terminal-service.js'; const logger = createLogger('Terminal'); diff --git a/apps/server/src/routes/terminal/routes/auth.ts b/apps/server/src/routes/terminal/routes/auth.ts index 1d6156bd..0aa29b34 100644 --- a/apps/server/src/routes/terminal/routes/auth.ts +++ b/apps/server/src/routes/terminal/routes/auth.ts @@ -9,7 +9,6 @@ import { generateToken, addToken, getTokenExpiryMs, - getErrorMessage, } from '../common.js'; export function createAuthHandler() { diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 75c3a437..5ceb50bf 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -2,59 +2,26 @@ * Common utilities for worktree routes */ -import { createLogger } from '@automaker/utils'; -import { spawnProcess } from '@automaker/platform'; +import { + createLogger, + isValidBranchName, + isValidRemoteName, + MAX_BRANCH_NAME_LENGTH, +} from '@automaker/utils'; import { exec } from 'child_process'; import { promisify } from 'util'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; +// Re-export execGitCommand from the canonical shared module so any remaining +// consumers that import from this file continue to work. +export { execGitCommand } from '../../lib/git.js'; + const logger = createLogger('Worktree'); export const execAsync = promisify(exec); -// ============================================================================ -// 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 - * @returns Promise resolving to stdout output - * @throws Error with stderr message if command fails - * - * @example - * ```typescript - * // Safe: no injection possible - * await execGitCommand(['branch', '-D', branchName], projectPath); - * - * // Instead of unsafe: - * // await execAsync(`git branch -D ${branchName}`, { cwd }); - * ``` - */ -export async function execGitCommand(args: string[], cwd: string): Promise { - const result = await spawnProcess({ - command: 'git', - args, - cwd, - }); - - // spawnProcess returns { stdout, stderr, exitCode } - if (result.exitCode === 0) { - return result.stdout; - } else { - const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`; - throw new Error(errorMessage); - } -} - -// ============================================================================ -// Constants -// ============================================================================ - -/** Maximum allowed length for git branch names */ -export const MAX_BRANCH_NAME_LENGTH = 250; +// Re-export git validation utilities from the canonical shared module so +// existing consumers that import from this file continue to work. +export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH }; // ============================================================================ // Extended PATH configuration for Electron apps @@ -98,19 +65,6 @@ export const execEnv = { PATH: extendedPath, }; -// ============================================================================ -// Validation utilities -// ============================================================================ - -/** - * Validate branch name to prevent command injection. - * Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars. - * We also reject shell metacharacters for safety. - */ -export function isValidBranchName(name: string): boolean { - return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH; -} - /** * Check if gh CLI is available on the system */ diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 992a7b48..2525c831 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -51,9 +51,25 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import { createCommitLogHandler } from './routes/commit-log.js'; import { createDiscardChangesHandler } from './routes/discard-changes.js'; import { createListRemotesHandler } from './routes/list-remotes.js'; import { createAddRemoteHandler } from './routes/add-remote.js'; +import { createStashPushHandler } from './routes/stash-push.js'; +import { createStashListHandler } from './routes/stash-list.js'; +import { createStashApplyHandler } from './routes/stash-apply.js'; +import { createStashDropHandler } from './routes/stash-drop.js'; +import { createCherryPickHandler } from './routes/cherry-pick.js'; +import { createBranchCommitLogHandler } from './routes/branch-commit-log.js'; +import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js'; +import { createRebaseHandler } from './routes/rebase.js'; +import { createAbortOperationHandler } from './routes/abort-operation.js'; +import { createContinueOperationHandler } from './routes/continue-operation.js'; +import { createStageFilesHandler } from './routes/stage-files.js'; +import { createCheckChangesHandler } from './routes/check-changes.js'; +import { createSetTrackingHandler } from './routes/set-tracking.js'; +import { createSyncHandler } from './routes/sync.js'; +import { createUpdatePRNumberHandler } from './routes/update-pr-number.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createWorktreeRoutes( @@ -71,12 +87,22 @@ export function createWorktreeRoutes( '/merge', validatePathParams('projectPath'), requireValidProject, - createMergeHandler() + createMergeHandler(events) + ); + router.post( + '/create', + validatePathParams('projectPath'), + createCreateHandler(events, settingsService) ); - router.post('/create', validatePathParams('projectPath'), createCreateHandler(events)); router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post('/create-pr', createCreatePRHandler()); router.post('/pr-info', createPRInfoHandler()); + router.post( + '/update-pr-number', + validatePathParams('worktreePath', 'projectPath?'), + requireValidWorktree, + createUpdatePRNumberHandler() + ); router.post( '/commit', validatePathParams('worktreePath'), @@ -101,14 +127,42 @@ export function createWorktreeRoutes( requireValidWorktree, createPullHandler() ); - router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler()); + router.post( + '/sync', + validatePathParams('worktreePath'), + requireValidWorktree, + createSyncHandler() + ); + router.post( + '/set-tracking', + validatePathParams('worktreePath'), + requireValidWorktree, + createSetTrackingHandler() + ); + router.post( + '/checkout-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createCheckoutBranchHandler(events) + ); + router.post( + '/check-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createCheckChangesHandler() + ); router.post( '/list-branches', validatePathParams('worktreePath'), requireValidWorktree, createListBranchesHandler() ); - router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); + router.post( + '/switch-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createSwitchBranchHandler(events) + ); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.post( '/open-in-terminal', @@ -187,5 +241,95 @@ export function createWorktreeRoutes( createAddRemoteHandler() ); + // Commit log route + router.post( + '/commit-log', + validatePathParams('worktreePath'), + requireValidWorktree, + createCommitLogHandler(events) + ); + + // Stash routes + router.post( + '/stash-push', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashPushHandler(events) + ); + router.post( + '/stash-list', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashListHandler(events) + ); + router.post( + '/stash-apply', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashApplyHandler(events) + ); + router.post( + '/stash-drop', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashDropHandler(events) + ); + + // Cherry-pick route + router.post( + '/cherry-pick', + validatePathParams('worktreePath'), + requireValidWorktree, + createCherryPickHandler(events) + ); + + // Generate PR description route + router.post( + '/generate-pr-description', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGeneratePRDescriptionHandler(settingsService) + ); + + // Branch commit log route (get commits from a specific branch) + router.post( + '/branch-commit-log', + validatePathParams('worktreePath'), + requireValidWorktree, + createBranchCommitLogHandler(events) + ); + + // Rebase route + router.post( + '/rebase', + validatePathParams('worktreePath'), + requireValidWorktree, + createRebaseHandler(events) + ); + + // Abort in-progress merge/rebase/cherry-pick + router.post( + '/abort-operation', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createAbortOperationHandler(events) + ); + + // Continue in-progress merge/rebase/cherry-pick after resolving conflicts + router.post( + '/continue-operation', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createContinueOperationHandler(events) + ); + + // Stage/unstage files route + router.post( + '/stage-files', + validatePathParams('worktreePath', 'files[]'), + requireGitRepoOnly, + createStageFilesHandler() + ); + return router; } diff --git a/apps/server/src/routes/worktree/routes/abort-operation.ts b/apps/server/src/routes/worktree/routes/abort-operation.ts new file mode 100644 index 00000000..297e2ac8 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/abort-operation.ts @@ -0,0 +1,117 @@ +/** + * POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick + * + * Detects which operation (merge, rebase, or cherry-pick) is in progress + * and aborts it, returning the repository to a clean state. + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as fs from 'fs/promises'; +import { getErrorMessage, logError, execAsync } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; + +/** + * Detect what type of conflict operation is currently in progress + */ +async function detectOperation( + worktreePath: string +): Promise<'merge' | 'rebase' | 'cherry-pick' | null> { + try { + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { + cwd: worktreePath, + }); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] = + await Promise.all([ + fs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + if (rebaseMergeExists || rebaseApplyExists) return 'rebase'; + if (mergeHeadExists) return 'merge'; + if (cherryPickHeadExists) return 'cherry-pick'; + return null; + } catch { + return null; + } +} + +export function createAbortOperationHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + const resolvedWorktreePath = path.resolve(worktreePath); + + // Detect what operation is in progress + const operation = await detectOperation(resolvedWorktreePath); + + if (!operation) { + res.status(400).json({ + success: false, + error: 'No merge, rebase, or cherry-pick in progress', + }); + return; + } + + // Abort the operation + let abortCommand: string; + switch (operation) { + case 'merge': + abortCommand = 'git merge --abort'; + break; + case 'rebase': + abortCommand = 'git rebase --abort'; + break; + case 'cherry-pick': + abortCommand = 'git cherry-pick --abort'; + break; + } + + await execAsync(abortCommand, { cwd: resolvedWorktreePath }); + + // Emit event + events.emit('conflict:aborted', { + worktreePath: resolvedWorktreePath, + operation, + }); + + res.json({ + success: true, + result: { + operation, + message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`, + }, + }); + } catch (error) { + logError(error, 'Abort operation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/branch-commit-log.ts b/apps/server/src/routes/worktree/routes/branch-commit-log.ts new file mode 100644 index 00000000..60562d97 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/branch-commit-log.ts @@ -0,0 +1,92 @@ +/** + * POST /branch-commit-log endpoint - Get recent commit history for a specific branch + * + * Similar to commit-log but allows specifying a branch name to get commits from + * any branch, not just the currently checked out one. Useful for cherry-pick workflows + * where you need to browse commits from other branches. + * + * The handler only validates input, invokes the service, streams lifecycle events + * via the EventEmitter, and sends the final JSON response. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js'; +import { isValidBranchName } from '@automaker/utils'; + +export function createBranchCommitLogHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { + worktreePath, + branchName, + limit = 20, + } = req.body as { + worktreePath: string; + branchName?: string; + limit?: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Validate branchName before forwarding to execGitCommand. + // Reject values that start with '-', contain NUL, contain path-traversal + // sequences, or include characters outside the safe whitelist. + // An absent branchName is allowed (the service defaults it to HEAD). + if (branchName !== undefined && !isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: 'Invalid branchName: value contains unsafe characters or sequences', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('branchCommitLog:start', { + worktreePath, + branchName: branchName || 'HEAD', + limit, + }); + + // Delegate all Git work to the service + const result = await getBranchCommitLog(worktreePath, branchName, limit); + + // Emit progress with the number of commits fetched + events.emit('branchCommitLog:progress', { + worktreePath, + branchName: result.branch, + commitsLoaded: result.total, + }); + + // Emit done event + events.emit('branchCommitLog:done', { + worktreePath, + branchName: result.branch, + total: result.total, + }); + + res.json({ + success: true, + result, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('branchCommitLog:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Get branch commit log failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/branch-tracking.ts b/apps/server/src/routes/worktree/routes/branch-tracking.ts index 1c9f069a..4144b94a 100644 --- a/apps/server/src/routes/worktree/routes/branch-tracking.ts +++ b/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -31,8 +31,8 @@ export async function getTrackedBranches(projectPath: string): Promise " separator + const rawPath = line.slice(3); + const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath; + + if (x === '?' && y === '?') { + untracked.push(filePath); + } else { + if (x !== ' ' && x !== '?') { + staged.push(filePath); + } + if (y !== ' ' && y !== '?') { + unstaged.push(filePath); + } + } + } + + return { staged, unstaged, untracked }; +} + +export function createCheckChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Get porcelain status (includes staged, unstaged, and untracked files) + const stdout = await execGitCommand(['status', '--porcelain'], worktreePath); + + const { staged, unstaged, untracked } = parseStatusOutput(stdout); + + const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0; + + // Deduplicate file paths across staged, unstaged, and untracked arrays + // to avoid double-counting partially staged files + const uniqueFilePaths = new Set([...staged, ...unstaged, ...untracked]); + + res.json({ + success: true, + result: { + hasChanges, + staged, + unstaged, + untracked, + totalFiles: uniqueFilePaths.size, + }, + }); + } catch (error) { + logError(error, 'Check changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index ffa6e5e3..97b4419b 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -1,23 +1,69 @@ /** * POST /checkout-branch endpoint - Create and checkout a new branch * + * Supports automatic stash handling: when `stashChanges` is true, local changes + * are stashed before creating the branch and reapplied after. If the stash pop + * results in merge conflicts, returns a special response so the UI can create a + * conflict resolution task. + * + * Git business logic is delegated to checkout-branch-service.ts when stash + * handling is requested. Otherwise, falls back to the original simple flow. + * * Note: Git repository validation (isGitRepo, hasCommits) is handled by - * the requireValidWorktree middleware in index.ts + * the requireValidWorktree middleware in index.ts. + * Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams + * middleware in index.ts. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; +import path from 'path'; +import { stat } from 'fs/promises'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performCheckoutBranch } from '../../../services/checkout-branch-service.js'; +import { createLogger } from '@automaker/utils'; -const execAsync = promisify(exec); +const logger = createLogger('CheckoutBranchRoute'); -export function createCheckoutBranchHandler() { +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * Non-fatal: fetch errors are logged and swallowed so the workflow continues. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (error instanceof Error && error.message === 'Process aborted') { + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs + } finally { + clearTimeout(timerId); + } +} + +export function createCheckoutBranchHandler(events?: EventEmitter) { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, branchName } = req.body as { + const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as { worktreePath: string; branchName: string; + baseBranch?: string; + /** When true, stash local changes before checkout and reapply after */ + stashChanges?: boolean; + /** When true, include untracked files in the stash (defaults to true) */ + includeUntracked?: boolean; }; if (!worktreePath) { @@ -36,28 +82,94 @@ export function createCheckoutBranchHandler() { return; } - // Validate branch name (basic validation) - const invalidChars = /[\s~^:?*\[\\]/; - if (invalidChars.test(branchName)) { + // Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/ + if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: 'Branch name contains invalid characters', + error: + 'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', }); return; } - // Get current branch for reference - const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + // Validate base branch if provided + if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') { + res.status(400).json({ + success: false, + error: + 'Invalid base branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', + }); + return; + } + + // Resolve and validate worktreePath to prevent traversal attacks. + const resolvedPath = path.resolve(worktreePath); + try { + const stats = await stat(resolvedPath); + if (!stats.isDirectory()) { + res.status(400).json({ + success: false, + error: 'worktreePath is not a directory', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'worktreePath does not exist or is not accessible', + }); + return; + } + + // Use the service for stash-aware checkout + if (stashChanges) { + const result = await performCheckoutBranch( + resolvedPath, + branchName, + baseBranch, + { + stashChanges: true, + includeUntracked: includeUntracked ?? true, + }, + events + ); + + if (!result.success) { + const statusCode = isBranchError(result.error) ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + ...(result.stashPopConflicts !== undefined && { + stashPopConflicts: result.stashPopConflicts, + }), + ...(result.stashPopConflictMessage && { + stashPopConflictMessage: result.stashPopConflictMessage, + }), + }); + return; + } + + res.json({ + success: true, + result: result.result, + }); + return; + } + + // Original simple flow (no stash handling) + // Fetch latest remote refs before creating the branch so that + // base branch validation works for remote references like "origin/main" + await fetchRemotes(resolvedPath); + + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + resolvedPath + ); const currentBranch = currentBranchOutput.trim(); // Check if branch already exists try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); - // Branch exists + await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath); res.status(400).json({ success: false, error: `Branch '${branchName}' already exists`, @@ -67,10 +179,25 @@ export function createCheckoutBranchHandler() { // Branch doesn't exist, good to create } + // If baseBranch is provided, verify it exists before using it + if (baseBranch) { + try { + await execGitCommand(['rev-parse', '--verify', baseBranch], resolvedPath); + } catch { + res.status(400).json({ + success: false, + error: `Base branch '${baseBranch}' does not exist`, + }); + return; + } + } + // Create and checkout the new branch - await execAsync(`git checkout -b ${branchName}`, { - cwd: worktreePath, - }); + const checkoutArgs = ['checkout', '-b', branchName]; + if (baseBranch) { + checkoutArgs.push(baseBranch); + } + await execGitCommand(checkoutArgs, resolvedPath); res.json({ success: true, @@ -81,8 +208,22 @@ export function createCheckoutBranchHandler() { }, }); } catch (error) { + events?.emit('switch:error', { + error: getErrorMessage(error), + }); + logError(error, 'Checkout branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; } + +/** + * Determine whether an error message represents a client error (400). + * Stash failures are server-side errors and are intentionally excluded here + * so they are returned as HTTP 500 rather than HTTP 400. + */ +function isBranchError(error?: string): boolean { + if (!error) return false; + return error.includes('already exists') || error.includes('does not exist'); +} diff --git a/apps/server/src/routes/worktree/routes/cherry-pick.ts b/apps/server/src/routes/worktree/routes/cherry-pick.ts new file mode 100644 index 00000000..8f404a0f --- /dev/null +++ b/apps/server/src/routes/worktree/routes/cherry-pick.ts @@ -0,0 +1,107 @@ +/** + * POST /cherry-pick endpoint - Cherry-pick one or more commits into the current branch + * + * Applies commits from another branch onto the current branch. + * Supports single or multiple commit cherry-picks. + * + * Git business logic is delegated to cherry-pick-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * The global event emitter is passed into the service so all lifecycle + * events (started, success, conflict, abort, verify-failed) are broadcast + * to WebSocket clients. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { verifyCommits, runCherryPick } from '../../../services/cherry-pick-service.js'; + +export function createCherryPickHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, commitHashes, options } = req.body as { + worktreePath: string; + commitHashes: string[]; + options?: { + noCommit?: boolean; + }; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + // Normalize the path to prevent path traversal and ensure consistent paths + const resolvedWorktreePath = path.resolve(worktreePath); + + if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) { + res.status(400).json({ + success: false, + error: 'commitHashes array is required and must contain at least one commit hash', + }); + return; + } + + // Validate each commit hash format (should be hex string) + for (const hash of commitHashes) { + if (!/^[a-fA-F0-9]+$/.test(hash)) { + res.status(400).json({ + success: false, + error: `Invalid commit hash format: "${hash}"`, + }); + return; + } + } + + // Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing + const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events); + if (invalidHash !== null) { + res.status(400).json({ + success: false, + error: `Commit "${invalidHash}" does not exist`, + }); + return; + } + + // Execute the cherry-pick via the service. + // The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict, + // and cherry-pick:abort at the appropriate lifecycle points. + const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events); + + if (result.success) { + res.json({ + success: true, + result: { + cherryPicked: result.cherryPicked, + commitHashes: result.commitHashes, + branch: result.branch, + message: result.message, + }, + }); + } else if (result.hasConflicts) { + res.status(409).json({ + success: false, + error: result.error, + hasConflicts: true, + aborted: result.aborted, + }); + } + } catch (error) { + // Emit failure event for unexpected (non-conflict) errors + events.emit('cherry-pick:failure', { + error: getErrorMessage(error), + }); + + logError(error, 'Cherry-pick failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/commit-log.ts b/apps/server/src/routes/worktree/routes/commit-log.ts new file mode 100644 index 00000000..dbdce1c3 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/commit-log.ts @@ -0,0 +1,72 @@ +/** + * POST /commit-log endpoint - Get recent commit history for a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to commit-log-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getCommitLog } from '../../../services/commit-log-service.js'; + +export function createCommitLogHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, limit = 20 } = req.body as { + worktreePath: string; + limit?: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('commitLog:start', { + worktreePath, + limit, + }); + + // Delegate all Git work to the service + const result = await getCommitLog(worktreePath, limit); + + // Emit progress with the number of commits fetched + events.emit('commitLog:progress', { + worktreePath, + branch: result.branch, + commitsLoaded: result.total, + }); + + // Emit complete event + events.emit('commitLog:complete', { + worktreePath, + branch: result.branch, + total: result.total, + }); + + res.json({ + success: true, + result, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('commitLog:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Get commit log failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/commit.ts b/apps/server/src/routes/worktree/routes/commit.ts index f33cd94b..1bfbfd58 100644 --- a/apps/server/src/routes/worktree/routes/commit.ts +++ b/apps/server/src/routes/worktree/routes/commit.ts @@ -6,18 +6,20 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export function createCommitHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, message } = req.body as { + const { worktreePath, message, files } = req.body as { worktreePath: string; message: string; + files?: string[]; }; if (!worktreePath || !message) { @@ -44,11 +46,21 @@ export function createCommitHandler() { return; } - // Stage all changes - await execAsync('git add -A', { cwd: worktreePath }); + // Stage changes - either specific files or all changes + if (files && files.length > 0) { + // Reset any previously staged changes first + await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }).catch(() => { + // Ignore errors from reset (e.g., if nothing is staged) + }); + // Stage only the selected files (args array avoids shell injection) + await execFileAsync('git', ['add', ...files], { cwd: worktreePath }); + } else { + // Stage all changes (original behavior) + await execFileAsync('git', ['add', '-A'], { cwd: worktreePath }); + } - // Create commit - await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { + // Create commit (pass message as arg to avoid shell injection) + await execFileAsync('git', ['commit', '-m', message], { cwd: worktreePath, }); diff --git a/apps/server/src/routes/worktree/routes/continue-operation.ts b/apps/server/src/routes/worktree/routes/continue-operation.ts new file mode 100644 index 00000000..e7582c02 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/continue-operation.ts @@ -0,0 +1,151 @@ +/** + * POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick + * + * After conflicts have been resolved, this endpoint continues the operation. + * For merge: performs git commit (merge is auto-committed after conflict resolution) + * For rebase: runs git rebase --continue + * For cherry-pick: runs git cherry-pick --continue + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as fs from 'fs/promises'; +import { getErrorMessage, logError, execAsync } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; + +/** + * Detect what type of conflict operation is currently in progress + */ +async function detectOperation( + worktreePath: string +): Promise<'merge' | 'rebase' | 'cherry-pick' | null> { + try { + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { + cwd: worktreePath, + }); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] = + await Promise.all([ + fs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + if (rebaseMergeExists || rebaseApplyExists) return 'rebase'; + if (mergeHeadExists) return 'merge'; + if (cherryPickHeadExists) return 'cherry-pick'; + return null; + } catch { + return null; + } +} + +/** + * Check if there are still unmerged paths (unresolved conflicts) + */ +async function hasUnmergedPaths(worktreePath: string): Promise { + try { + const { stdout: statusOutput } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + } catch { + return false; + } +} + +export function createContinueOperationHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + const resolvedWorktreePath = path.resolve(worktreePath); + + // Detect what operation is in progress + const operation = await detectOperation(resolvedWorktreePath); + + if (!operation) { + res.status(400).json({ + success: false, + error: 'No merge, rebase, or cherry-pick in progress', + }); + return; + } + + // Check for unresolved conflicts + if (await hasUnmergedPaths(resolvedWorktreePath)) { + res.status(409).json({ + success: false, + error: + 'There are still unresolved conflicts. Please resolve all conflicts before continuing.', + hasUnresolvedConflicts: true, + }); + return; + } + + // Stage all resolved files first + await execAsync('git add -A', { cwd: resolvedWorktreePath }); + + // Continue the operation + let continueCommand: string; + switch (operation) { + case 'merge': + // For merge, we need to commit after resolving conflicts + continueCommand = 'git commit --no-edit'; + break; + case 'rebase': + continueCommand = 'git rebase --continue'; + break; + case 'cherry-pick': + continueCommand = 'git cherry-pick --continue'; + break; + } + + await execAsync(continueCommand, { + cwd: resolvedWorktreePath, + env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening + }); + + // Emit event + events.emit('conflict:resolved', { + worktreePath: resolvedWorktreePath, + operation, + }); + + res.json({ + success: true, + result: { + operation, + message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`, + }, + }); + } catch (error) { + logError(error, 'Continue operation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index 87777c69..de63aea9 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -9,27 +9,43 @@ import { execAsync, execEnv, isValidBranchName, + isValidRemoteName, isGhCliAvailable, } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { spawnProcess } from '@automaker/platform'; import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; import { createLogger } from '@automaker/utils'; import { validatePRState } from '@automaker/types'; +import { resolvePrTarget } from '../../../services/pr-service.js'; const logger = createLogger('CreatePR'); export function createCreatePRHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = - req.body as { - worktreePath: string; - projectPath?: string; - commitMessage?: string; - prTitle?: string; - prBody?: string; - baseBranch?: string; - draft?: boolean; - }; + const { + worktreePath, + projectPath, + commitMessage, + prTitle, + prBody, + baseBranch, + draft, + remote, + targetRemote, + } = req.body as { + worktreePath: string; + projectPath?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + baseBranch?: string; + draft?: boolean; + remote?: string; + /** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */ + targetRemote?: string; + }; if (!worktreePath) { res.status(400).json({ @@ -59,6 +75,52 @@ export function createCreatePRHandler() { return; } + // --- Input validation: run all validation before any git write operations --- + + // Validate remote names before use to prevent command injection + if (remote !== undefined && !isValidRemoteName(remote)) { + res.status(400).json({ + success: false, + error: 'Invalid remote name contains unsafe characters', + }); + return; + } + if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) { + res.status(400).json({ + success: false, + error: 'Invalid target remote name contains unsafe characters', + }); + return; + } + + const pushRemote = remote || 'origin'; + + // Resolve repository URL, fork workflow, and target remote information. + // This is needed for both the existing PR check and PR creation. + // Resolve early so validation errors are caught before any writes. + let repoUrl: string | null = null; + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + try { + const prTarget = await resolvePrTarget({ + worktreePath, + pushRemote, + targetRemote, + }); + repoUrl = prTarget.repoUrl; + upstreamRepo = prTarget.upstreamRepo; + originOwner = prTarget.originOwner; + } catch (resolveErr) { + // resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote) + res.status(400).json({ + success: false, + error: getErrorMessage(resolveErr), + }); + return; + } + + // --- Validation complete — proceed with git operations --- + // Check for uncommitted changes logger.debug(`Checking for uncommitted changes in: ${worktreePath}`); const { stdout: status } = await execAsync('git status --porcelain', { @@ -82,12 +144,9 @@ export function createCreatePRHandler() { logger.debug(`Running: git add -A`); await execAsync('git add -A', { cwd: worktreePath, env: execEnv }); - // Create commit + // Create commit — pass message as a separate arg to avoid shell injection logger.debug(`Running: git commit`); - await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { - cwd: worktreePath, - env: execEnv, - }); + await execGitCommand(['commit', '-m', message], worktreePath); // Get commit hash const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { @@ -110,20 +169,19 @@ export function createCreatePRHandler() { } } - // Push the branch to remote + // Push the branch to remote (use selected remote or default to 'origin') + // Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName. let pushError: string | null = null; try { - await execAsync(`git push -u origin ${branchName}`, { - cwd: worktreePath, - env: execEnv, - }); - } catch (error: unknown) { + await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv); + } catch { // If push fails, try with --set-upstream try { - await execAsync(`git push --set-upstream origin ${branchName}`, { - cwd: worktreePath, - env: execEnv, - }); + await execGitCommand( + ['push', '--set-upstream', pushRemote, branchName], + worktreePath, + execEnv + ); } catch (error2: unknown) { // Capture push error for reporting const err = error2 as { stderr?: string; message?: string }; @@ -145,82 +203,11 @@ export function createCreatePRHandler() { const base = baseBranch || 'main'; const title = prTitle || branchName; const body = prBody || `Changes from branch ${branchName}`; - const draftFlag = draft ? '--draft' : ''; - let prUrl: string | null = null; let prError: string | null = null; let browserUrl: string | null = null; let ghCliAvailable = false; - // Get repository URL and detect fork workflow FIRST - // This is needed for both the existing PR check and PR creation - let repoUrl: string | null = null; - let upstreamRepo: string | null = null; - let originOwner: string | null = null; - try { - const { stdout: remotes } = await execAsync('git remote -v', { - cwd: worktreePath, - env: execEnv, - }); - - // Parse remotes to detect fork workflow and get repo URL - const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings - for (const line of lines) { - // Try multiple patterns to match different remote URL formats - // Pattern 1: git@github.com:owner/repo.git (fetch) - // Pattern 2: https://github.com/owner/repo.git (fetch) - // Pattern 3: https://github.com/owner/repo (fetch) - let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/); - if (!match) { - // Try SSH format: git@github.com:owner/repo.git - match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); - } - if (!match) { - // Try HTTPS format: https://github.com/owner/repo.git - match = line.match( - /^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ - ); - } - - if (match) { - const [, remoteName, owner, repo] = match; - if (remoteName === 'upstream') { - upstreamRepo = `${owner}/${repo}`; - repoUrl = `https://github.com/${owner}/${repo}`; - } else if (remoteName === 'origin') { - originOwner = owner; - if (!repoUrl) { - repoUrl = `https://github.com/${owner}/${repo}`; - } - } - } - } - } catch (error) { - // Couldn't parse remotes - will try fallback - } - - // Fallback: Try to get repo URL from git config if remote parsing failed - if (!repoUrl) { - try { - const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { - cwd: worktreePath, - env: execEnv, - }); - const url = originUrl.trim(); - - // Parse URL to extract owner/repo - // Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) - let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); - if (match) { - const [, owner, repo] = match; - originOwner = owner; - repoUrl = `https://github.com/${owner}/${repo}`; - } - } catch (error) { - // Failed to get repo URL from config - } - } - // Check if gh CLI is available (cross-platform) ghCliAvailable = await isGhCliAvailable(); @@ -228,13 +215,16 @@ export function createCreatePRHandler() { if (repoUrl) { const encodedTitle = encodeURIComponent(title); const encodedBody = encodeURIComponent(body); + // Encode base branch and head branch to handle special chars like # or % + const encodedBase = encodeURIComponent(base); + const encodedBranch = encodeURIComponent(branchName); if (upstreamRepo && originOwner) { - // Fork workflow: PR to upstream from origin - browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + // Fork workflow (or cross-remote PR): PR to target from push remote + browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`; } else { // Regular repo - browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`; } } @@ -244,18 +234,40 @@ export function createCreatePRHandler() { if (ghCliAvailable) { // First, check if a PR already exists for this branch using gh pr list // This is more reliable than gh pr view as it explicitly searches by branch name - // For forks, we need to use owner:branch format for the head parameter + // For forks/cross-remote, we need to use owner:branch format for the head parameter const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; - const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : ''; logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); try { - const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`; - logger.debug(`Running: ${listCmd}`); - const { stdout: existingPrOutput } = await execAsync(listCmd, { + const listArgs = ['pr', 'list']; + if (upstreamRepo) { + listArgs.push('--repo', upstreamRepo); + } + listArgs.push( + '--head', + headRef, + '--json', + 'number,title,url,state,createdAt', + '--limit', + '1' + ); + logger.debug(`Running: gh ${listArgs.join(' ')}`); + const listResult = await spawnProcess({ + command: 'gh', + args: listArgs, cwd: worktreePath, env: execEnv, }); + if (listResult.exitCode !== 0) { + logger.error( + `gh pr list failed with exit code ${listResult.exitCode}: ` + + `stderr=${listResult.stderr}, stdout=${listResult.stdout}` + ); + throw new Error( + `gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}` + ); + } + const existingPrOutput = listResult.stdout; logger.debug(`gh pr list output: ${existingPrOutput}`); const existingPrs = JSON.parse(existingPrOutput); @@ -275,7 +287,7 @@ export function createCreatePRHandler() { url: existingPr.url, title: existingPr.title || title, state: validatePRState(existingPr.state), - createdAt: new Date().toISOString(), + createdAt: existingPr.createdAt || new Date().toISOString(), }); logger.debug( `Stored existing PR info for branch ${branchName}: PR #${existingPr.number}` @@ -291,27 +303,35 @@ export function createCreatePRHandler() { // Only create a new PR if one doesn't already exist if (!prUrl) { try { - // Build gh pr create command - let prCmd = `gh pr create --base "${base}"`; + // Build gh pr create args as an array to avoid shell injection on + // title/body (backticks, $, \ were unsafe with string interpolation) + const prArgs = ['pr', 'create', '--base', base]; // If this is a fork (has upstream remote), specify the repo and head if (upstreamRepo && originOwner) { // For forks: --repo specifies where to create PR, --head specifies source - prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`; + prArgs.push('--repo', upstreamRepo, '--head', `${originOwner}:${branchName}`); } else { // Not a fork, just specify the head branch - prCmd += ` --head "${branchName}"`; + prArgs.push('--head', branchName); } - prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`; - prCmd = prCmd.trim(); + prArgs.push('--title', title, '--body', body); + if (draft) prArgs.push('--draft'); - logger.debug(`Creating PR with command: ${prCmd}`); - const { stdout: prOutput } = await execAsync(prCmd, { + logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`); + const prResult = await spawnProcess({ + command: 'gh', + args: prArgs, cwd: worktreePath, env: execEnv, }); - prUrl = prOutput.trim(); + if (prResult.exitCode !== 0) { + throw Object.assign(new Error(prResult.stderr || 'gh pr create failed'), { + stderr: prResult.stderr, + }); + } + prUrl = prResult.stdout.trim(); logger.info(`PR created: ${prUrl}`); // Extract PR number and store metadata for newly created PR @@ -345,11 +365,26 @@ export function createCreatePRHandler() { if (errorMessage.toLowerCase().includes('already exists')) { logger.debug(`PR already exists error - trying to fetch existing PR`); try { - const { stdout: viewOutput } = await execAsync( - `gh pr view --json number,title,url,state`, - { cwd: worktreePath, env: execEnv } - ); - const existingPr = JSON.parse(viewOutput); + // Build args as an array to avoid shell injection. + // When upstreamRepo is set (fork/cross-remote workflow) we must + // query the upstream repository so we find the correct PR. + const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt']; + if (upstreamRepo) { + viewArgs.push('--repo', upstreamRepo); + } + logger.debug(`Running: gh ${viewArgs.join(' ')}`); + const viewResult = await spawnProcess({ + command: 'gh', + args: viewArgs, + cwd: worktreePath, + env: execEnv, + }); + if (viewResult.exitCode !== 0) { + throw new Error( + `gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}` + ); + } + const existingPr = JSON.parse(viewResult.stdout); if (existingPr.url) { prUrl = existingPr.url; prNumber = existingPr.number; @@ -361,7 +396,7 @@ export function createCreatePRHandler() { url: existingPr.url, title: existingPr.title || title, state: validatePRState(existingPr.state), - createdAt: new Date().toISOString(), + createdAt: existingPr.createdAt || new Date().toISOString(), }); logger.debug(`Fetched and stored existing PR: #${existingPr.number}`); } diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 061fa801..9b4417b8 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -4,7 +4,8 @@ * This endpoint handles worktree creation with proper checks: * 1. First checks if git already has a worktree for the branch (anywhere) * 2. If found, returns the existing worktree (no error) - * 3. Only creates a new worktree if none exists for the branch + * 3. Syncs the base branch from its remote tracking branch (fast-forward only) + * 4. Only creates a new worktree if none exists for the branch */ import type { Request, Response } from 'express'; @@ -13,6 +14,8 @@ import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import type { EventEmitter } from '../../../lib/events.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { WorktreeService } from '../../../services/worktree-service.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, @@ -20,14 +23,21 @@ import { normalizePath, ensureInitialCommit, isValidBranchName, - execGitCommand, } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; import { runInitScript } from '../../../services/init-script-service.js'; +import { + syncBaseBranch, + type BaseBranchSyncResult, +} from '../../../services/branch-sync-service.js'; const logger = createLogger('Worktree'); +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + const execAsync = promisify(exec); /** @@ -81,13 +91,15 @@ async function findExistingWorktreeForBranch( } } -export function createCreateHandler(events: EventEmitter) { +export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) { + const worktreeService = new WorktreeService(); + return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, baseBranch } = req.body as { projectPath: string; branchName: string; - baseBranch?: string; // Optional base branch to create from (defaults to current HEAD) + baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main". }; if (!projectPath || !branchName) { @@ -167,6 +179,71 @@ export function createCreateHandler(events: EventEmitter) { // Create worktrees directory if it doesn't exist await secureFs.mkdir(worktreesDir, { recursive: true }); + // Fetch latest from all remotes before creating the worktree. + // This ensures remote refs are up-to-date for: + // - Remote base branches (e.g. "origin/main") + // - Existing remote branches being checked out as worktrees + // - Branch existence checks against fresh remote state + logger.info('Fetching from all remotes before creating worktree'); + try { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller); + } finally { + clearTimeout(timerId); + } + } catch (fetchErr) { + // Non-fatal: log but continue — refs might already be cached locally + logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`); + } + + // Sync the base branch with its remote tracking branch (fast-forward only). + // This ensures the new worktree starts from an up-to-date state rather than + // a potentially stale local copy. If the sync fails or the branch has diverged, + // we proceed with the local copy and inform the user. + const effectiveBase = baseBranch || 'HEAD'; + let syncResult: BaseBranchSyncResult = { attempted: false, synced: false }; + + // Only sync if the base is a real branch (not 'HEAD') + // Pass skipFetch=true because we already fetched all remotes above. + if (effectiveBase !== 'HEAD') { + logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`); + syncResult = await syncBaseBranch(projectPath, effectiveBase, true); + if (syncResult.attempted) { + if (syncResult.synced) { + logger.info(`Base branch sync result: ${syncResult.message}`); + } else { + logger.warn(`Base branch sync result: ${syncResult.message}`); + } + } + } else { + // When using HEAD, try to sync the currently checked-out branch + // Pass skipFetch=true because we already fetched all remotes above. + try { + const currentBranch = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + projectPath + ); + const trimmedBranch = currentBranch.trim(); + if (trimmedBranch && trimmedBranch !== 'HEAD') { + logger.info( + `Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree` + ); + syncResult = await syncBaseBranch(projectPath, trimmedBranch, true); + if (syncResult.attempted) { + if (syncResult.synced) { + logger.info(`HEAD branch sync result: ${syncResult.message}`); + } else { + logger.warn(`HEAD branch sync result: ${syncResult.message}`); + } + } + } + } catch { + // Could not determine HEAD branch — skip sync + } + } + // Check if branch exists (using array arguments to prevent injection) let branchExists = false; try { @@ -200,6 +277,33 @@ export function createCreateHandler(events: EventEmitter) { // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); + // Get the commit hash the new worktree is based on for logging + let baseCommitHash: string | undefined; + try { + const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath); + baseCommitHash = hash.trim(); + } catch { + // Non-critical — just for logging + } + + if (baseCommitHash) { + logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`); + } + + // Copy configured files into the new worktree before responding + // This runs synchronously to ensure files are in place before any init script + try { + await worktreeService.copyConfiguredFiles( + projectPath, + absoluteWorktreePath, + settingsService, + events + ); + } catch (copyErr) { + // Log but don't fail worktree creation – files may be partially copied + logger.warn('Some configured files failed to copy to worktree:', copyErr); + } + // Respond immediately (non-blocking) res.json({ success: true, @@ -207,6 +311,17 @@ export function createCreateHandler(events: EventEmitter) { path: normalizePath(absoluteWorktreePath), branch: branchName, isNew: !branchExists, + baseCommitHash, + ...(syncResult.attempted + ? { + syncResult: { + synced: syncResult.synced, + remote: syncResult.remote, + message: syncResult.message, + diverged: syncResult.diverged, + }, + } + : {}), }, }); diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts index 6814add9..fcb42f59 100644 --- a/apps/server/src/routes/worktree/routes/delete.ts +++ b/apps/server/src/routes/worktree/routes/delete.ts @@ -5,8 +5,10 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import fs from 'fs/promises'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); @@ -45,20 +47,79 @@ export function createDeleteHandler() { }); branchName = stdout.trim(); } catch { - // Could not get branch name + // Could not get branch name - worktree directory may already be gone + logger.debug('Could not determine branch for worktree, directory may be missing'); } // Remove the worktree (using array arguments to prevent injection) + let removeSucceeded = false; try { await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); - } catch (error) { - // Try with prune if remove fails - await execGitCommand(['worktree', 'prune'], projectPath); + removeSucceeded = true; + } catch (removeError) { + // `git worktree remove` can fail if the directory is already missing + // or in a bad state. Try pruning stale worktree entries as a fallback. + logger.debug('git worktree remove failed, trying prune', { + error: getErrorMessage(removeError), + }); + try { + await execGitCommand(['worktree', 'prune'], projectPath); + + // Verify the specific worktree is no longer registered after prune. + // `git worktree prune` exits 0 even if worktreePath was never registered, + // so we must explicitly check the worktree list to avoid false positives. + const { stdout: listOut } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + // Parse porcelain output and check for an exact path match. + // Using substring .includes() can produce false positives when one + // worktree path is a prefix of another (e.g. /foo vs /foobar). + const stillRegistered = listOut + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length).trim()) + .some((registeredPath) => registeredPath === worktreePath); + if (stillRegistered) { + // Prune didn't clean up our entry - treat as failure + throw removeError; + } + removeSucceeded = true; + } catch (pruneError) { + // If pruneError is the original removeError re-thrown, propagate it + if (pruneError === removeError) { + throw removeError; + } + logger.warn('git worktree prune also failed', { + error: getErrorMessage(pruneError), + }); + // If both remove and prune fail, still try to return success + // if the worktree directory no longer exists (it may have been + // manually deleted already). + let dirExists = false; + try { + await fs.access(worktreePath); + dirExists = true; + } catch { + // Directory doesn't exist + } + if (dirExists) { + // Directory still exists - this is a real failure + throw removeError; + } + // Directory is gone, treat as success + removeSucceeded = true; + } } - // Optionally delete the branch + // Optionally delete the branch (only if worktree was successfully removed) let branchDeleted = false; - if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') { + if ( + removeSucceeded && + deleteBranch && + branchName && + branchName !== 'main' && + branchName !== 'master' + ) { // Validate branch name to prevent command injection if (!isValidBranchName(branchName)) { logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 314fa8ce..1e8586bf 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -34,6 +34,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); return; } @@ -55,6 +56,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); } catch (innerError) { // Worktree doesn't exist - fallback to main project path @@ -71,6 +73,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); } catch (fallbackError) { logError(fallbackError, 'Fallback to main project also failed'); diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 4f15e053..eb2c9399 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -1,27 +1,79 @@ /** - * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * POST /discard-changes endpoint - Discard uncommitted changes in a worktree * - * This performs a destructive operation that: - * 1. Resets staged changes (git reset HEAD) - * 2. Discards modified tracked files (git checkout .) - * 3. Removes untracked files and directories (git clean -fd) + * Supports two modes: + * 1. Discard ALL changes (when no files array is provided) + * - Resets staged changes (git reset HEAD) + * - Discards modified tracked files (git checkout .) + * - Removes untracked files and directories (git clean -ffd) + * + * 2. Discard SELECTED files (when files array is provided) + * - Unstages selected staged files (git reset HEAD -- ) + * - Reverts selected tracked file changes (git checkout -- ) + * - Removes selected untracked files (git clean -ffd -- ) * * Note: Git repository validation (isGitRepo) is handled by * the requireGitRepoOnly middleware in index.ts */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; +import * as path from 'path'; +import * as fs from 'fs'; +import { getErrorMessage, logError } from '@automaker/utils'; +import { execGitCommand } from '../../../lib/git.js'; -const execAsync = promisify(exec); +/** + * Validate that a file path does not escape the worktree directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd) and + * rejects symlinks inside the worktree that point outside of it. + */ +function validateFilePath(filePath: string, worktreePath: string): boolean { + // Resolve the full path relative to the worktree (lexical resolution) + const resolved = path.resolve(worktreePath, filePath); + const normalizedWorktree = path.resolve(worktreePath); + + // First, perform lexical prefix check + const lexicalOk = + resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree; + if (!lexicalOk) { + return false; + } + + // Then, attempt symlink-aware validation using realpath. + // This catches symlinks inside the worktree that point outside of it. + try { + const realResolved = fs.realpathSync(resolved); + const realWorktree = fs.realpathSync(normalizedWorktree); + return realResolved.startsWith(realWorktree + path.sep) || realResolved === realWorktree; + } catch { + // If realpath fails (e.g., target doesn't exist yet for untracked files), + // fall back to the lexical startsWith check which already passed above. + return true; + } +} + +/** + * Parse a file path from git status --porcelain output, handling renames. + * For renamed files (R status), git reports "old_path -> new_path" and + * we need the new path to match what parseGitStatus() returns in git-utils. + */ +function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string { + const trimmedPath = rawPath.trim(); + if (indexStatus === 'R' || workTreeStatus === 'R') { + const arrowIndex = trimmedPath.indexOf(' -> '); + if (arrowIndex !== -1) { + return trimmedPath.slice(arrowIndex + 4); + } + } + return trimmedPath; +} export function createDiscardChangesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, files } = req.body as { worktreePath: string; + files?: string[]; }; if (!worktreePath) { @@ -33,9 +85,7 @@ export function createDiscardChangesHandler() { } // Check for uncommitted changes first - const { stdout: status } = await execAsync('git status --porcelain', { - cwd: worktreePath, - }); + const status = await execGitCommand(['status', '--porcelain'], worktreePath); if (!status.trim()) { res.json({ @@ -48,61 +98,216 @@ export function createDiscardChangesHandler() { return; } - // Count the files that will be affected - const lines = status.trim().split('\n').filter(Boolean); - const fileCount = lines.length; - // Get branch name before discarding - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + const branchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); const branchName = branchOutput.trim(); - // Discard all changes: - // 1. Reset any staged changes - await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there's nothing staged + // Parse the status output to categorize files + // Git --porcelain format: XY PATH where X=index status, Y=worktree status + // For renamed files: XY OLD_PATH -> NEW_PATH + const statusLines = status.trim().split('\n').filter(Boolean); + const allFiles = statusLines.map((line) => { + const fileStatus = line.substring(0, 2); + const rawPath = line.slice(3); + const indexStatus = fileStatus.charAt(0); + const workTreeStatus = fileStatus.charAt(1); + // Parse path consistently with parseGitStatus() in git-utils, + // which extracts the new path for renames + const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus); + return { status: fileStatus, path: filePath }; }); - // 2. Discard changes in tracked files - await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there are no tracked changes - }); + // Determine which files to discard + const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length; - // 3. Remove untracked files and directories - await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there are no untracked files - }); + if (isSelectiveDiscard) { + // Selective discard: only discard the specified files + const filesToDiscard = new Set(files); - // Verify all changes were discarded - const { stdout: finalStatus } = await execAsync('git status --porcelain', { - cwd: worktreePath, - }); + // Validate all requested file paths stay within the worktree + const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath)); + if (invalidPaths.length > 0) { + res.status(400).json({ + success: false, + error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`, + }); + return; + } + + // Separate files into categories for proper git operations + const trackedModified: string[] = []; // Modified/deleted tracked files + const stagedFiles: string[] = []; // Files that are staged + const untrackedFiles: string[] = []; // Untracked files (?) + const warnings: string[] = []; + + // Track which requested files were matched so we can handle unmatched ones + const matchedFiles = new Set(); + + for (const file of allFiles) { + if (!filesToDiscard.has(file.path)) continue; + matchedFiles.add(file.path); + + // file.status is the raw two-character XY git porcelain status (no trim) + // X = index/staging status, Y = worktree status + const xy = file.status.substring(0, 2); + const indexStatus = xy.charAt(0); + const workTreeStatus = xy.charAt(1); + + if (indexStatus === '?' && workTreeStatus === '?') { + untrackedFiles.push(file.path); + } else if (indexStatus === 'A') { + // Staged-new file: must be reset (unstaged) then cleaned (deleted). + // Never pass to trackedModified — the file has no HEAD version to + // check out, so `git checkout --` would fail or do nothing. + stagedFiles.push(file.path); + untrackedFiles.push(file.path); + } else { + // Check if the file has staged changes (index status X) + if (indexStatus !== ' ' && indexStatus !== '?') { + stagedFiles.push(file.path); + } + // Check for working tree changes (worktree status Y): handles MM, MD, etc. + if (workTreeStatus !== ' ' && workTreeStatus !== '?') { + trackedModified.push(file.path); + } + } + } + + // Handle files from the UI that didn't match any entry in allFiles. + // This can happen due to timing differences between the UI loading diffs + // and the discard request, or path format differences. + // Attempt to clean unmatched files directly as untracked files. + for (const requestedFile of files) { + if (!matchedFiles.has(requestedFile)) { + untrackedFiles.push(requestedFile); + } + } + + // 1. Unstage selected staged files (using execFile to bypass shell) + if (stagedFiles.length > 0) { + try { + await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to unstage files: ${msg}`); + warnings.push(`Failed to unstage some files: ${msg}`); + } + } + + // 2. Revert selected tracked file changes + if (trackedModified.length > 0) { + try { + await execGitCommand(['checkout', '--', ...trackedModified], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to revert tracked files: ${msg}`); + warnings.push(`Failed to revert some tracked files: ${msg}`); + } + } + + // 3. Remove selected untracked files + // Use -ffd (double force) to also handle nested git repositories + if (untrackedFiles.length > 0) { + try { + await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to clean untracked files: ${msg}`); + warnings.push(`Failed to remove some untracked files: ${msg}`); + } + } + + const fileCount = files.length; + + // Verify the remaining state + const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath); + + const remainingCount = finalStatus.trim() + ? finalStatus.trim().split('\n').filter(Boolean).length + : 0; + const actualDiscarded = allFiles.length - remainingCount; + + let message = + actualDiscarded < fileCount + ? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining` + : `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`; - if (finalStatus.trim()) { - // Some changes couldn't be discarded (possibly ignored files or permission issues) - const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; res.json({ success: true, result: { discarded: true, - filesDiscarded: fileCount - remainingCount, + filesDiscarded: actualDiscarded, filesRemaining: remainingCount, branch: branchName, - message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + message, + ...(warnings.length > 0 && { warnings }), }, }); } else { - res.json({ - success: true, - result: { - discarded: true, - filesDiscarded: fileCount, - filesRemaining: 0, - branch: branchName, - message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, - }, - }); + // Discard ALL changes (original behavior) + const fileCount = allFiles.length; + const warnings: string[] = []; + + // 1. Reset any staged changes + try { + await execGitCommand(['reset', 'HEAD'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git reset HEAD failed: ${msg}`); + warnings.push(`Failed to unstage changes: ${msg}`); + } + + // 2. Discard changes in tracked files + try { + await execGitCommand(['checkout', '.'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git checkout . failed: ${msg}`); + warnings.push(`Failed to revert tracked changes: ${msg}`); + } + + // 3. Remove untracked files and directories + // Use -ffd (double force) to also handle nested git repositories + try { + await execGitCommand(['clean', '-ffd', '--'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git clean -ffd failed: ${msg}`); + warnings.push(`Failed to remove untracked files: ${msg}`); + } + + // Verify all changes were discarded + const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath); + + if (finalStatus.trim()) { + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } } } catch (error) { logError(error, 'Discard changes failed'); diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index d0444ad0..ab3a3aca 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -6,7 +6,7 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js'; import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); /** Timeout for AI provider calls in milliseconds (30 seconds) */ const AI_TIMEOUT_MS = 30_000; @@ -33,20 +33,39 @@ async function* withTimeout( generator: AsyncIterable, timeoutMs: number ): AsyncGenerator { + let timerId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); + timerId = setTimeout( + () => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), + timeoutMs + ); }); const iterator = generator[Symbol.asyncIterator](); let done = false; - while (!done) { - const result = await Promise.race([iterator.next(), timeoutPromise]); - if (result.done) { - done = true; - } else { - yield result.value; + try { + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { + // Capture the original error, then attempt to close the iterator. + // If iterator.return() throws, log it but rethrow the original error + // so the timeout error (not the teardown error) is preserved. + try { + await iterator.return?.(); + } catch (teardownErr) { + logger.warn('Error during iterator cleanup after timeout:', teardownErr); + } + throw err; + }); + if (result.done) { + done = true; + } else { + yield result.value; + } } + } finally { + clearTimeout(timerId); } } @@ -117,14 +136,14 @@ export function createGenerateCommitMessageHandler( let diff = ''; try { // First try to get staged changes - const { stdout: stagedDiff } = await execAsync('git diff --cached', { + const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { cwd: worktreePath, maxBuffer: 1024 * 1024 * 5, // 5MB buffer }); // If no staged changes, get unstaged changes if (!stagedDiff.trim()) { - const { stdout: unstagedDiff } = await execAsync('git diff', { + const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { cwd: worktreePath, maxBuffer: 1024 * 1024 * 5, // 5MB buffer }); @@ -213,14 +232,16 @@ export function createGenerateCommitMessageHandler( } } } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if available (some providers return final text here) - responseText = msg.result; + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } } } const message = responseText.trim(); - if (!message || message.trim().length === 0) { + if (!message) { logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { success: false, diff --git a/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/apps/server/src/routes/worktree/routes/generate-pr-description.ts new file mode 100644 index 00000000..a588f82d --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -0,0 +1,491 @@ +/** + * POST /worktree/generate-pr-description endpoint - Generate an AI PR description from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a pull request + * title and description from the branch's changes compared to the base branch. + * Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { createLogger } from '@automaker/utils'; +import { isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; + +const logger = createLogger('GeneratePRDescription'); +const execFileAsync = promisify(execFile); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** Max diff size to send to AI (characters) */ +const MAX_DIFF_SIZE = 15_000; + +const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided. + +IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else. + +Output your response in EXACTLY this format (including the markers): +---TITLE--- + +---BODY--- +## Summary +<1-3 bullet points describing the key changes> + +## Changes + + +Rules: +- Your ENTIRE response must start with ---TITLE--- and contain nothing before it +- The title should be concise and descriptive (50-72 characters) +- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle") +- The description should explain WHAT changed and WHY +- Group related changes together +- Use markdown formatting for the body +- Do NOT include the branch name in the title +- Focus on the user-facing impact when possible +- If there are breaking changes, mention them prominently +- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created +- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes +- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff +- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`; + +/** + * Wraps an async generator with a timeout. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + let timerId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout( + () => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + try { + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { + // Timeout (or other error) — attempt to gracefully close the source generator + await iterator.return?.(); + throw err; + }); + if (result.done) { + done = true; + } else { + yield result.value; + } + } + } finally { + clearTimeout(timerId); + } +} + +interface GeneratePRDescriptionRequestBody { + worktreePath: string; + baseBranch?: string; +} + +interface GeneratePRDescriptionSuccessResponse { + success: true; + title: string; + body: string; +} + +interface GeneratePRDescriptionErrorResponse { + success: false; + error: string; +} + +export function createGeneratePRDescriptionHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, baseBranch } = req.body as GeneratePRDescriptionRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + res.status(400).json(response); + return; + } + + // Validate baseBranch to allow only safe branch name characters + if (baseBranch !== undefined && !/^[\w.\-/]+$/.test(baseBranch)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'baseBranch contains invalid characters', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating PR description for worktree: ${worktreePath}`); + + // Get current branch name + const { stdout: branchOutput } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: worktreePath } + ); + const branchName = branchOutput.trim(); + + // Determine the base branch for comparison + const base = baseBranch || 'main'; + + // Collect diffs in three layers and combine them: + // 1. Committed changes on the branch: `git diff base...HEAD` + // 2. Staged (cached) changes not yet committed: `git diff --cached` + // 3. Unstaged changes to tracked files: `git diff` (no --cached flag) + // + // Untracked files are intentionally excluded — they are typically build artifacts, + // planning files, hidden dotfiles, or other files unrelated to the PR. + // `git diff` and `git diff --cached` only show changes to files already tracked by git, + // which is exactly the correct scope. + // + // We combine all three sources and deduplicate by file path so that a file modified + // in commits AND with additional uncommitted changes is not double-counted. + + /** Parse a unified diff into per-file hunks keyed by file path */ + function parseDiffIntoFileHunks(diffText: string): Map { + const fileHunks = new Map(); + if (!diffText.trim()) return fileHunks; + + // Split on "diff --git" boundaries (keep the delimiter) + const sections = diffText.split(/(?=^diff --git )/m); + for (const section of sections) { + if (!section.trim()) continue; + // Use a back-reference pattern so the "b/" side must match the "a/" capture, + // correctly handling paths that contain " b/" in their name. + // Falls back to a two-capture pattern to handle renames (a/ and b/ differ). + const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m); + const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null; + const match = backrefMatch || renameMatch; + if (match) { + // Prefer the backref capture (identical paths); for renames use the destination (match[2]) + const filePath = backrefMatch ? match[1] : match[2]; + // Merge hunks if the same file appears in multiple diff sources + const existing = fileHunks.get(filePath) ?? ''; + fileHunks.set(filePath, existing + section); + } + } + return fileHunks; + } + + // --- Step 1: committed changes (branch vs base) --- + let committedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + committedDiff = stdout; + } catch { + // Base branch may not exist locally; try the remote tracking branch + try { + const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + committedDiff = stdout; + } catch { + // Cannot compare against base — leave committedDiff empty; the uncommitted + // changes gathered below will still be included. + logger.warn(`Could not get committed diff against ${base} or origin/${base}`); + } + } + + // --- Step 2: staged changes (tracked files only) --- + let stagedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff', '--cached'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + stagedDiff = stdout; + } catch (err) { + // Non-fatal — staged diff is a best-effort supplement + logger.debug('Failed to get staged diff', err); + } + + // --- Step 3: unstaged changes (tracked files only) --- + let unstagedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + unstagedDiff = stdout; + } catch (err) { + // Non-fatal — unstaged diff is a best-effort supplement + logger.debug('Failed to get unstaged diff', err); + } + + // --- Combine and deduplicate --- + // Build a map of filePath → diff content by concatenating hunks from all sources + // in chronological order (committed → staged → unstaged) so that no changes + // are lost when a file appears in multiple diff sources. + const combinedFileHunks = new Map(); + + for (const source of [committedDiff, stagedDiff, unstagedDiff]) { + const hunks = parseDiffIntoFileHunks(source); + for (const [filePath, hunk] of hunks) { + if (combinedFileHunks.has(filePath)) { + combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk); + } else { + combinedFileHunks.set(filePath, hunk); + } + } + } + + const diff = Array.from(combinedFileHunks.values()).join(''); + + // Log what files were included for observability + if (combinedFileHunks.size > 0) { + logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`); + logger.debug( + `PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}` + ); + } + + // Also get the commit log for context — always scoped to the selected base branch + // so the log only contains commits that are part of this PR. + // We do NOT fall back to an unscoped `git log` because that would include commits + // from the base branch itself and produce misleading AI context. + let commitLog = ''; + try { + const { stdout: logOutput } = await execFileAsync( + 'git', + ['log', `${base}..HEAD`, '--oneline', '--no-decorate'], + { + cwd: worktreePath, + maxBuffer: 1024 * 1024, + } + ); + commitLog = logOutput.trim(); + } catch { + // Base branch not available locally — try the remote tracking branch + try { + const { stdout: logOutput } = await execFileAsync( + 'git', + ['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'], + { + cwd: worktreePath, + maxBuffer: 1024 * 1024, + } + ); + commitLog = logOutput.trim(); + } catch { + // Cannot scope commit log to base branch — leave empty rather than + // including unscoped commits that would pollute the AI context. + logger.warn(`Could not get commit log against ${base} or origin/${base}`); + } + } + + if (!diff.trim() && !commitLog.trim()) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'No changes found to generate a PR description from', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long + const truncatedDiff = + diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + '\n\n[... diff truncated ...]' + : diff; + + // Build the user prompt + let userPrompt = `Generate a pull request title and description for the following changes.\n\nBranch: ${branchName}\nBase Branch: ${base}\n`; + + if (commitLog) { + userPrompt += `\nCommit History:\n${commitLog}\n`; + } + + if (truncatedDiff) { + userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + } + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider: claudeCompatibleProvider, + credentials, + } = await getPhaseModelWithOverrides( + 'commitMessageModel', + settingsService, + worktreePath, + '[GeneratePRDescription]' + ); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info( + `Using model for PR description: ${model}`, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); + + // Get provider for the model type + const aiProvider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + // For Cursor models, combine prompts + const effectivePrompt = isCursorModel(model) + ? `${PR_DESCRIPTION_SYSTEM_PROMPT}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : PR_DESCRIPTION_SYSTEM_PROMPT; + + logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`); + + let responseText = ''; + const stream = aiProvider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + thinkingLevel, + claudeCompatibleProvider, + credentials, + }); + + // Wrap with timeout + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } + } + } + + const fullResponse = responseText.trim(); + + if (!fullResponse || fullResponse.length === 0) { + logger.warn('Received empty response from model'); + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'Failed to generate PR description - empty response', + }; + res.status(500).json(response); + return; + } + + // Parse the response to extract title and body. + // The model may include conversational preamble before the structured markers, + // so we search for the markers anywhere in the response, not just at the start. + let title = ''; + let body = ''; + + const titleMatch = fullResponse.match(/---TITLE---\s*\n([\s\S]*?)(?=---BODY---|$)/); + const bodyMatch = fullResponse.match(/---BODY---\s*\n([\s\S]*?)$/); + + if (titleMatch && bodyMatch) { + title = titleMatch[1].trim(); + body = bodyMatch[1].trim(); + } else { + // Fallback: try to extract meaningful content, skipping any conversational preamble. + // Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc. + const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0); + + // Skip lines that look like conversational preamble + let startIndex = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // Check if this line looks like conversational AI preamble + if ( + /^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test( + line + ) || + /^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test( + line + ) + ) { + startIndex = i + 1; + continue; + } + break; + } + + // Use remaining lines after skipping preamble + const contentLines = lines.slice(startIndex); + if (contentLines.length > 0) { + title = contentLines[0].trim(); + body = contentLines.slice(1).join('\n').trim(); + } else { + // If all lines were filtered as preamble, use the original first non-empty line + title = lines[0]?.trim() || ''; + body = lines.slice(1).join('\n').trim(); + } + } + + // Clean up title - remove any markdown headings, quotes, or marker artifacts + title = title + .replace(/^#+\s*/, '') + .replace(/^["']|["']$/g, '') + .replace(/^---\w+---\s*/, ''); + + logger.info(`Generated PR title: ${title.substring(0, 100)}...`); + + const response: GeneratePRDescriptionSuccessResponse = { + success: true, + title, + body, + }; + res.json(response); + } catch (error) { + logError(error, 'Generate PR description failed'); + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 2e6a34f5..ca2a33c4 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -6,11 +6,13 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import { getErrorMessage, logWorktreeError } from '../common.js'; +import { getRemotesWithBranch } from '../../../services/worktree-service.js'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); interface BranchInfo { name: string; @@ -92,6 +94,9 @@ export function createListBranchesHandler() { // Skip HEAD pointers like "origin/HEAD" if (cleanName.includes('/HEAD')) return; + // Skip bare remote names without a branch (e.g. "origin" by itself) + if (!cleanName.includes('/')) return; + // Only add remote branches if a branch with the exact same name isn't already // in the list. This avoids duplicates if a local branch is named like a remote one. // Note: We intentionally include remote branches even when a local branch with the @@ -126,17 +131,28 @@ export function createListBranchesHandler() { let aheadCount = 0; let behindCount = 0; let hasRemoteBranch = false; + let trackingRemote: string | undefined; + // List of remote names that have a branch matching the current branch name + let remotesWithBranch: string[] = []; try { // First check if there's a remote tracking branch - const { stdout: upstreamOutput } = await execAsync( - `git rev-parse --abbrev-ref ${currentBranch}@{upstream}`, + const { stdout: upstreamOutput } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', `${currentBranch}@{upstream}`], { cwd: worktreePath } ); - if (upstreamOutput.trim()) { + const upstreamRef = upstreamOutput.trim(); + if (upstreamRef) { hasRemoteBranch = true; - const { stdout: aheadBehindOutput } = await execAsync( - `git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`, + // Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin") + const slashIndex = upstreamRef.indexOf('/'); + if (slashIndex !== -1) { + trackingRemote = upstreamRef.slice(0, slashIndex); + } + const { stdout: aheadBehindOutput } = await execFileAsync( + 'git', + ['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`], { cwd: worktreePath } ); const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number); @@ -147,8 +163,9 @@ export function createListBranchesHandler() { // No upstream branch set - check if the branch exists on any remote try { // Check if there's a matching branch on origin (most common remote) - const { stdout: remoteBranchOutput } = await execAsync( - `git ls-remote --heads origin ${currentBranch}`, + const { stdout: remoteBranchOutput } = await execFileAsync( + 'git', + ['ls-remote', '--heads', 'origin', currentBranch], { cwd: worktreePath, timeout: 5000 } ); hasRemoteBranch = remoteBranchOutput.trim().length > 0; @@ -158,6 +175,12 @@ export function createListBranchesHandler() { } } + // Check which remotes have a branch matching the current branch name. + // This helps the UI distinguish between "branch exists on tracking remote" vs + // "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin'). + // Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true) + remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes); + res.json({ success: true, result: { @@ -167,6 +190,8 @@ export function createListBranchesHandler() { behindCount, hasRemoteBranch, hasAnyRemotes, + trackingRemote, + remotesWithBranch, }, }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 0f8021f1..333ba7c2 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -58,6 +58,90 @@ interface WorktreeInfo { hasChanges?: boolean; changedFilesCount?: number; pr?: WorktreePRInfo; // PR info if a PR has been created for this branch + /** Whether there are actual unresolved conflict files (conflictFiles.length > 0) */ + hasConflicts?: boolean; + /** Type of git operation in progress (merge/rebase/cherry-pick), set independently of hasConflicts */ + conflictType?: 'merge' | 'rebase' | 'cherry-pick'; + /** List of files with conflicts */ + conflictFiles?: string[]; +} + +/** + * Detect if a merge, rebase, or cherry-pick is in progress for a worktree. + * Checks for the presence of state files/directories that git creates + * during these operations. + */ +async function detectConflictState(worktreePath: string): Promise<{ + hasConflicts: boolean; + conflictType?: 'merge' | 'rebase' | 'cherry-pick'; + conflictFiles?: string[]; +}> { + try { + // Find the canonical .git directory for this worktree + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { + cwd: worktreePath, + timeout: 15000, + }); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + // Check for merge, rebase, and cherry-pick state files/directories + const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] = + await Promise.all([ + secureFs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined; + if (rebaseMergeExists || rebaseApplyExists) { + conflictType = 'rebase'; + } else if (mergeHeadExists) { + conflictType = 'merge'; + } else if (cherryPickHeadExists) { + conflictType = 'cherry-pick'; + } + + if (!conflictType) { + return { hasConflicts: false }; + } + + // Get list of conflicted files using machine-readable git status + let conflictFiles: string[] = []; + try { + const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', { + cwd: worktreePath, + timeout: 15000, + }); + conflictFiles = statusOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // Fall back to empty list if diff fails + } + + return { + hasConflicts: conflictFiles.length > 0, + conflictType, + conflictFiles, + }; + } catch { + // If anything fails, assume no conflicts + return { hasConflicts: false }; + } } async function getCurrentBranch(cwd: string): Promise { @@ -373,7 +457,7 @@ export function createListHandler() { // Read all worktree metadata to get PR info const allMetadata = await readAllWorktreeMetadata(projectPath); - // If includeDetails is requested, fetch change status for each worktree + // If includeDetails is requested, fetch change status and conflict state for each worktree if (includeDetails) { for (const worktree of worktrees) { try { @@ -390,6 +474,21 @@ export function createListHandler() { worktree.hasChanges = false; worktree.changedFilesCount = 0; } + + // Detect merge/rebase/cherry-pick in progress + try { + const conflictState = await detectConflictState(worktree.path); + // Always propagate conflictType so callers know an operation is in progress, + // even when there are no unresolved conflict files yet. + if (conflictState.conflictType) { + worktree.conflictType = conflictState.conflictType; + } + // hasConflicts is true only when there are actual unresolved files + worktree.hasConflicts = conflictState.hasConflicts; + worktree.conflictFiles = conflictState.conflictFiles; + } catch { + // Ignore conflict detection errors + } } } diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index 48df7893..bcd6fbc9 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -8,15 +8,11 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; -import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performMerge } from '../../../services/merge-service.js'; -const execAsync = promisify(exec); -const logger = createLogger('Worktree'); - -export function createMergeHandler() { +export function createMergeHandler(events: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as { @@ -24,7 +20,12 @@ export function createMergeHandler() { branchName: string; worktreePath: string; targetBranch?: string; // Branch to merge into (defaults to 'main') - options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean }; + options?: { + squash?: boolean; + message?: string; + deleteWorktreeAndBranch?: boolean; + remote?: string; + }; }; if (!projectPath || !branchName || !worktreePath) { @@ -38,102 +39,41 @@ export function createMergeHandler() { // Determine the target branch (default to 'main') const mergeTo = targetBranch || 'main'; - // Validate source branch exists - try { - await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); - } catch { - res.status(400).json({ - success: false, - error: `Branch "${branchName}" does not exist`, - }); - return; - } + // Delegate all merge logic to the service + const result = await performMerge( + projectPath, + branchName, + worktreePath, + mergeTo, + options, + events + ); - // Validate target branch exists - try { - await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); - } catch { - res.status(400).json({ - success: false, - error: `Target branch "${mergeTo}" does not exist`, - }); - return; - } - - // Merge the feature branch into the target branch - const mergeCmd = options?.squash - ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; - - try { - await execAsync(mergeCmd, { cwd: projectPath }); - } catch (mergeError: unknown) { - // Check if this is a merge conflict - const err = mergeError as { stdout?: string; stderr?: string; message?: string }; - const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; - const hasConflicts = - output.includes('CONFLICT') || output.includes('Automatic merge failed'); - - if (hasConflicts) { + if (!result.success) { + if (result.hasConflicts) { // Return conflict-specific error message that frontend can detect res.status(409).json({ success: false, - error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + error: result.error, hasConflicts: true, + conflictFiles: result.conflictFiles, }); return; } - // Re-throw non-conflict errors to be handled by outer catch - throw mergeError; - } - - // If squash merge, need to commit - if (options?.squash) { - await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { - cwd: projectPath, + // Non-conflict service errors (e.g. branch not found, invalid name) + res.status(400).json({ + success: false, + error: result.error, }); - } - - // Optionally delete the worktree and branch after merging - let worktreeDeleted = false; - let branchDeleted = false; - - if (options?.deleteWorktreeAndBranch) { - // Remove the worktree - try { - await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); - worktreeDeleted = true; - } catch { - // Try with prune if remove fails - try { - await execGitCommand(['worktree', 'prune'], projectPath); - worktreeDeleted = true; - } catch { - logger.warn(`Failed to remove worktree: ${worktreePath}`); - } - } - - // Delete the branch (but not main/master) - if (branchName !== 'main' && branchName !== 'master') { - if (!isValidBranchName(branchName)) { - logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); - } else { - try { - await execGitCommand(['branch', '-D', branchName], projectPath); - branchDeleted = true; - } catch { - logger.warn(`Failed to delete branch: ${branchName}`); - } - } - } + return; } res.json({ success: true, - mergedBranch: branchName, - targetBranch: mergeTo, - deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + mergedBranch: result.mergedBranch, + targetBranch: result.targetBranch, + deleted: result.deleted, }); } catch (error) { logError(error, 'Merge worktree failed'); diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index c5ea6f9e..f0d620d4 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -125,19 +125,14 @@ export function createOpenInEditorHandler() { `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` ); - try { - const result = await openInFileManager(worktreePath); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${result.editorName}`, - editorName: result.editorName, - }, - }); - } catch (fallbackError) { - // Both editor and file manager failed - throw fallbackError; - } + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); } } catch (error) { logError(error, 'Open in editor failed'); diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 7b922994..3c9f0665 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -1,22 +1,33 @@ /** * POST /pull endpoint - Pull latest changes for a worktree/branch * + * Enhanced pull flow with stash management and conflict detection: + * 1. Checks for uncommitted local changes (staged and unstaged) + * 2. If local changes exist AND stashIfNeeded is true, automatically stashes them + * 3. Performs the git pull + * 4. If changes were stashed, attempts to reapply via git stash pop + * 5. Detects merge conflicts from both pull and stash reapplication + * 6. Returns structured conflict information for AI-assisted resolution + * + * Git business logic is delegated to pull-service.ts. + * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts */ 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); +import { performPull } from '../../../services/pull-service.js'; +import type { PullResult } from '../../../services/pull-service.js'; export function createPullHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, remote, stashIfNeeded } = req.body as { worktreePath: string; + remote?: string; + /** When true, automatically stash local changes before pulling and reapply after */ + stashIfNeeded?: boolean; }; if (!worktreePath) { @@ -27,67 +38,69 @@ export function createPullHandler() { return; } - // Get current branch name - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); - const branchName = branchOutput.trim(); + // Execute the pull via the service + const result = await performPull(worktreePath, { remote, stashIfNeeded }); - // Fetch latest from remote - await execAsync('git fetch origin', { cwd: worktreePath }); - - // Check if there are local changes that would be overwritten - const { stdout: status } = await execAsync('git status --porcelain', { - cwd: worktreePath, - }); - const hasLocalChanges = status.trim().length > 0; - - if (hasLocalChanges) { - res.status(400).json({ - success: false, - error: 'You have local changes. Please commit them before pulling.', - }); - return; - } - - // Pull latest changes - try { - const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, { - cwd: worktreePath, - }); - - // Check if we pulled any changes - const alreadyUpToDate = pullOutput.includes('Already up to date'); - - res.json({ - success: true, - result: { - branch: branchName, - pulled: !alreadyUpToDate, - message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', - }, - }); - } catch (pullError: unknown) { - const err = pullError as { stderr?: string; message?: string }; - const errorMsg = err.stderr || err.message || 'Pull failed'; - - // Check for common errors - if (errorMsg.includes('no tracking information')) { - res.status(400).json({ - success: false, - error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`, - }); - return; - } - - res.status(500).json({ - success: false, - error: errorMsg, - }); - } + // Map service result to HTTP response + mapResultToResponse(res, result); } catch (error) { logError(error, 'Pull failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; } + +/** + * Map a PullResult from the service to the appropriate HTTP response. + * + * - Successful results (including local-changes-detected info) → 200 + * - Validation/state errors (detached HEAD, no upstream) → 400 + * - Operational errors (fetch/stash/pull failures) → 500 + */ +function mapResultToResponse(res: Response, result: PullResult): void { + if (!result.success && result.error) { + // Determine the appropriate HTTP status for errors + const statusCode = isClientError(result.error) ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + ...(result.stashRecoveryFailed && { stashRecoveryFailed: true }), + }); + return; + } + + // Success case (includes partial success like local changes detected, conflicts, etc.) + res.json({ + success: true, + result: { + branch: result.branch, + pulled: result.pulled, + hasLocalChanges: result.hasLocalChanges, + localChangedFiles: result.localChangedFiles, + hasConflicts: result.hasConflicts, + conflictSource: result.conflictSource, + conflictFiles: result.conflictFiles, + stashed: result.stashed, + stashRestored: result.stashRestored, + message: result.message, + isMerge: result.isMerge, + isFastForward: result.isFastForward, + mergeAffectedFiles: result.mergeAffectedFiles, + }, + }); +} + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + * + * Client errors are validation issues or invalid git state that the user + * needs to resolve (e.g. detached HEAD, no upstream, no tracking info). + */ +function isClientError(errorMessage: string): boolean { + return ( + errorMessage.includes('detached HEAD') || + errorMessage.includes('has no upstream branch') || + errorMessage.includes('no tracking information') + ); +} diff --git a/apps/server/src/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts index 0e082b3f..0bf7bc3c 100644 --- a/apps/server/src/routes/worktree/routes/push.ts +++ b/apps/server/src/routes/worktree/routes/push.ts @@ -1,24 +1,24 @@ /** * POST /push endpoint - Push a worktree branch to remote * + * Git business logic is delegated to push-service.ts. + * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts */ 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); +import { performPush } from '../../../services/push-service.js'; export function createPushHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, force, remote } = req.body as { + const { worktreePath, force, remote, autoResolve } = req.body as { worktreePath: string; force?: boolean; remote?: string; + autoResolve?: boolean; }; if (!worktreePath) { @@ -29,34 +29,28 @@ export function createPushHandler() { return; } - // Get branch name - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); - const branchName = branchOutput.trim(); + const result = await performPush(worktreePath, { remote, force, autoResolve }); - // Use specified remote or default to 'origin' - const targetRemote = remote || 'origin'; - - // Push the branch - const forceFlag = force ? '--force' : ''; - try { - await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, - }); - } catch { - // Try setting upstream - await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, { - cwd: worktreePath, + if (!result.success) { + const statusCode = isClientError(result.error ?? '') ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + diverged: result.diverged, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, }); + return; } res.json({ success: true, result: { - branch: branchName, - pushed: true, - message: `Successfully pushed ${branchName} to ${targetRemote}`, + branch: result.branch, + pushed: result.pushed, + diverged: result.diverged, + autoResolved: result.autoResolved, + message: result.message, }, }); } catch (error) { @@ -65,3 +59,15 @@ export function createPushHandler() { } }; } + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + */ +function isClientError(errorMessage: string): boolean { + return ( + errorMessage.includes('detached HEAD') || + errorMessage.includes('rejected') || + errorMessage.includes('diverged') + ); +} diff --git a/apps/server/src/routes/worktree/routes/rebase.ts b/apps/server/src/routes/worktree/routes/rebase.ts new file mode 100644 index 00000000..05dc1e6a --- /dev/null +++ b/apps/server/src/routes/worktree/routes/rebase.ts @@ -0,0 +1,135 @@ +/** + * POST /rebase endpoint - Rebase the current branch onto a target branch + * + * Rebases the current worktree branch onto a specified target branch + * (e.g., origin/main) for a linear history. Detects conflicts and + * returns structured conflict information for AI-assisted resolution. + * + * Git business logic is delegated to rebase-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { runRebase } from '../../../services/rebase-service.js'; + +export function createRebaseHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, ontoBranch, remote } = req.body as { + worktreePath: string; + /** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */ + ontoBranch: string; + /** Remote name to fetch from before rebasing (defaults to 'origin') */ + remote?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!ontoBranch) { + res.status(400).json({ + success: false, + error: 'ontoBranch is required', + }); + return; + } + + // Normalize the path to prevent path traversal and ensure consistent paths + const resolvedWorktreePath = path.resolve(worktreePath); + + // Validate the branch name (allow remote refs like origin/main) + if (!isValidBranchName(ontoBranch)) { + res.status(400).json({ + success: false, + error: `Invalid branch name: "${ontoBranch}"`, + }); + return; + } + + // Validate optional remote name to reject unsafe characters at the route layer + if (remote !== undefined && !isValidRemoteName(remote)) { + res.status(400).json({ + success: false, + error: `Invalid remote name: "${remote}"`, + }); + return; + } + + // Emit started event + events.emit('rebase:started', { + worktreePath: resolvedWorktreePath, + ontoBranch, + }); + + // Execute the rebase via the service + const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote }); + + if (result.success) { + // Emit success event + events.emit('rebase:success', { + worktreePath: resolvedWorktreePath, + branch: result.branch, + ontoBranch: result.ontoBranch, + }); + + res.json({ + success: true, + result: { + branch: result.branch, + ontoBranch: result.ontoBranch, + message: result.message, + }, + }); + } else if (result.hasConflicts) { + // Emit conflict event + events.emit('rebase:conflict', { + worktreePath: resolvedWorktreePath, + ontoBranch, + conflictFiles: result.conflictFiles, + aborted: result.aborted, + }); + + res.status(409).json({ + success: false, + error: result.error, + hasConflicts: true, + conflictFiles: result.conflictFiles, + aborted: result.aborted, + }); + } else { + // Emit failure event for non-conflict failures + events.emit('rebase:failure', { + worktreePath: resolvedWorktreePath, + branch: result.branch, + ontoBranch: result.ontoBranch, + error: result.error, + }); + + res.status(500).json({ + success: false, + error: result.error ?? 'Rebase failed', + hasConflicts: false, + }); + } + } catch (error) { + // Emit failure event + events.emit('rebase:failure', { + error: getErrorMessage(error), + }); + + logError(error, 'Rebase failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/set-tracking.ts b/apps/server/src/routes/worktree/routes/set-tracking.ts new file mode 100644 index 00000000..9d63e013 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/set-tracking.ts @@ -0,0 +1,76 @@ +/** + * POST /set-tracking endpoint - Set the upstream tracking branch for a worktree + * + * Sets `git branch --set-upstream-to=/` for the current branch. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { execGitCommand } from '@automaker/git-utils'; +import { getErrorMessage, logError } from '../common.js'; +import { getCurrentBranch } from '../../../lib/git.js'; + +export function createSetTrackingHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remote, branch } = req.body as { + worktreePath: string; + remote: string; + branch?: string; + }; + + if (!worktreePath) { + res.status(400).json({ success: false, error: 'worktreePath required' }); + return; + } + + if (!remote) { + res.status(400).json({ success: false, error: 'remote required' }); + return; + } + + // Get current branch if not provided + let targetBranch = branch; + if (!targetBranch) { + try { + targetBranch = await getCurrentBranch(worktreePath); + } catch (err) { + res.status(400).json({ + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }); + return; + } + + if (targetBranch === 'HEAD') { + res.status(400).json({ + success: false, + error: 'Cannot set tracking in detached HEAD state.', + }); + return; + } + } + + // Set upstream tracking (pass local branch name as final arg to be explicit) + await execGitCommand( + ['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch], + worktreePath + ); + + res.json({ + success: true, + result: { + branch: targetBranch, + remote, + upstream: `${remote}/${targetBranch}`, + message: `Set tracking branch to ${remote}/${targetBranch}`, + }, + }); + } catch (error) { + logError(error, 'Set tracking branch failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stage-files.ts b/apps/server/src/routes/worktree/routes/stage-files.ts new file mode 100644 index 00000000..d04813e7 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stage-files.ts @@ -0,0 +1,74 @@ +/** + * POST /stage-files endpoint - Stage or unstage files in a worktree + * + * Supports two operations: + * 1. Stage files: `git add ` (adds files to the staging area) + * 2. Unstage files: `git reset HEAD -- ` (removes files from staging area) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +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 => { + try { + const { worktreePath, files, operation } = req.body as { + worktreePath: string; + files: string[]; + operation: 'stage' | 'unstage'; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath 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(worktreePath, 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-apply.ts b/apps/server/src/routes/worktree/routes/stash-apply.ts new file mode 100644 index 00000000..f854edd3 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-apply.ts @@ -0,0 +1,78 @@ +/** + * POST /stash-apply endpoint - Apply or pop a stash in a worktree + * + * Applies a specific stash entry to the working directory. + * Can either "apply" (keep stash) or "pop" (remove stash after applying). + * + * All git operations and conflict detection are delegated to StashService. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { applyOrPop } from '../../../services/stash-service.js'; + +export function createStashApplyHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, stashIndex, pop } = req.body as { + worktreePath: string; + stashIndex: number; + pop?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (stashIndex === undefined || stashIndex === null) { + res.status(400).json({ + success: false, + error: 'stashIndex required', + }); + return; + } + + const idx = typeof stashIndex === 'string' ? Number(stashIndex) : stashIndex; + + if (!Number.isInteger(idx) || idx < 0) { + res.status(400).json({ + success: false, + error: 'stashIndex must be a non-negative integer', + }); + return; + } + + // Delegate all stash apply/pop logic to the service + const result = await applyOrPop(worktreePath, idx, { pop }, events); + + if (!result.success) { + // applyOrPop already logs the error internally via logError — no need to double-log here + res.status(500).json({ success: false, error: result.error }); + return; + } + + res.json({ + success: true, + result: { + applied: result.applied, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, + operation: result.operation, + stashIndex: result.stashIndex, + message: result.message, + }, + }); + } catch (error) { + logError(error, 'Stash apply failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-drop.ts b/apps/server/src/routes/worktree/routes/stash-drop.ts new file mode 100644 index 00000000..a05985ee --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-drop.ts @@ -0,0 +1,83 @@ +/** + * POST /stash-drop endpoint - Drop (delete) a stash entry + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { dropStash } from '../../../services/stash-service.js'; + +export function createStashDropHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, stashIndex } = req.body as { + worktreePath: string; + stashIndex: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (!Number.isInteger(stashIndex) || stashIndex < 0) { + res.status(400).json({ + success: false, + error: 'stashIndex required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + stashIndex, + stashRef: `stash@{${stashIndex}}`, + operation: 'drop', + }); + + // Delegate all Git work to the service + const result = await dropStash(worktreePath, stashIndex); + + // Emit success event + events.emit('stash:success', { + worktreePath, + stashIndex, + operation: 'drop', + dropped: result.dropped, + }); + + res.json({ + success: true, + result: { + dropped: result.dropped, + stashIndex: result.stashIndex, + message: result.message, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + stashIndex: req.body?.stashIndex, + operation: 'drop', + error: getErrorMessage(error), + }); + + logError(error, 'Stash drop failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-list.ts b/apps/server/src/routes/worktree/routes/stash-list.ts new file mode 100644 index 00000000..c34b3878 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-list.ts @@ -0,0 +1,76 @@ +/** + * POST /stash-list endpoint - List all stashes in a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { listStash } from '../../../services/stash-service.js'; + +export function createStashListHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + operation: 'list', + }); + + // Delegate all Git work to the service + const result = await listStash(worktreePath); + + // Emit progress with stash count + events.emit('stash:progress', { + worktreePath, + operation: 'list', + total: result.total, + }); + + // Emit success event + events.emit('stash:success', { + worktreePath, + operation: 'list', + total: result.total, + }); + + res.json({ + success: true, + result: { + stashes: result.stashes, + total: result.total, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + operation: 'list', + error: getErrorMessage(error), + }); + + logError(error, 'Stash list failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-push.ts b/apps/server/src/routes/worktree/routes/stash-push.ts new file mode 100644 index 00000000..d2be6701 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-push.ts @@ -0,0 +1,81 @@ +/** + * POST /stash-push endpoint - Stash changes in a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { pushStash } from '../../../services/stash-service.js'; + +export function createStashPushHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, message, files } = req.body as { + worktreePath: string; + message?: string; + files?: string[]; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + operation: 'push', + }); + + // Delegate all Git work to the service + const result = await pushStash(worktreePath, { message, files }); + + // Emit progress with stash result + events.emit('stash:progress', { + worktreePath, + operation: 'push', + stashed: result.stashed, + branch: result.branch, + }); + + // Emit success event + events.emit('stash:success', { + worktreePath, + operation: 'push', + stashed: result.stashed, + branch: result.branch, + }); + + res.json({ + success: true, + result: { + stashed: result.stashed, + branch: result.branch, + message: result.message, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + operation: 'push', + error: getErrorMessage(error), + }); + + logError(error, 'Stash push failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index 63be752b..abcdfdcd 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -1,67 +1,29 @@ /** * POST /switch-branch endpoint - Switch to an existing branch * - * Simple branch switching. - * If there are uncommitted changes, the switch will fail and - * the user should commit first. + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response code so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Also fetches the latest remote refs before switching to ensure accurate branch detection. + * + * Git business logic is delegated to worktree-branch-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performSwitchBranch } from '../../../services/worktree-branch-service.js'; -const execAsync = promisify(exec); - -function isUntrackedLine(line: string): boolean { - return line.startsWith('?? '); -} - -function isExcludedWorktreeLine(line: string): boolean { - return line.includes('.worktrees/') || line.endsWith('.worktrees'); -} - -function isBlockingChangeLine(line: string): boolean { - if (!line.trim()) return false; - if (isExcludedWorktreeLine(line)) return false; - if (isUntrackedLine(line)) return false; - return true; -} - -/** - * Check if there are uncommitted changes in the working directory - * Excludes .worktrees/ directory which is created by automaker - */ -async function hasUncommittedChanges(cwd: string): Promise { - try { - const { stdout } = await execAsync('git status --porcelain', { cwd }); - const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); - return lines.length > 0; - } catch { - return false; - } -} - -/** - * Get a summary of uncommitted changes for user feedback - * Excludes .worktrees/ directory - */ -async function getChangesSummary(cwd: string): Promise { - try { - const { stdout } = await execAsync('git status --short', { cwd }); - const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); - if (lines.length === 0) return ''; - if (lines.length <= 5) return lines.join(', '); - return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`; - } catch { - return 'unknown changes'; - } -} - -export function createSwitchBranchHandler() { +export function createSwitchBranchHandler(events?: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { worktreePath, branchName } = req.body as { @@ -85,62 +47,58 @@ export function createSwitchBranchHandler() { return; } - // Get current branch - const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); - const previousBranch = currentBranchOutput.trim(); - - if (previousBranch === branchName) { - res.json({ - success: true, - result: { - previousBranch, - currentBranch: branchName, - message: `Already on branch '${branchName}'`, - }, - }); - return; - } - - // Check if branch exists - try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); - } catch { + // Validate branch name using shared allowlist to prevent Git option injection + if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: `Branch '${branchName}' does not exist`, + error: 'Invalid branch name', }); return; } - // Check for uncommitted changes - if (await hasUncommittedChanges(worktreePath)) { - const summary = await getChangesSummary(worktreePath); - res.status(400).json({ + // Execute the branch switch via the service + const result = await performSwitchBranch(worktreePath, branchName, events); + + // Map service result to HTTP response + if (!result.success) { + // Determine status code based on error type + const statusCode = isBranchNotFoundError(result.error) ? 400 : 500; + res.status(statusCode).json({ success: false, - error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, - code: 'UNCOMMITTED_CHANGES', + error: result.error, + ...(result.stashPopConflicts !== undefined && { + stashPopConflicts: result.stashPopConflicts, + }), + ...(result.stashPopConflictMessage && { + stashPopConflictMessage: result.stashPopConflictMessage, + }), }); return; } - // Switch to the target branch - await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath }); - res.json({ success: true, - result: { - previousBranch, - currentBranch: branchName, - message: `Switched to branch '${branchName}'`, - }, + result: result.result, }); } catch (error) { + events?.emit('switch:error', { + error: getErrorMessage(error), + }); + logError(error, 'Switch branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; } + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + * + * Client errors are validation issues like non-existent branches or + * unparseable remote branch names. + */ +function isBranchNotFoundError(error?: string): boolean { + if (!error) return false; + return error.includes('does not exist') || error.includes('Failed to parse remote branch name'); +} diff --git a/apps/server/src/routes/worktree/routes/sync.ts b/apps/server/src/routes/worktree/routes/sync.ts new file mode 100644 index 00000000..acd2ec3b --- /dev/null +++ b/apps/server/src/routes/worktree/routes/sync.ts @@ -0,0 +1,66 @@ +/** + * POST /sync endpoint - Pull then push a worktree branch + * + * Performs a full sync operation: pull latest from remote, then push + * local commits. Handles divergence automatically. + * + * Git business logic is delegated to sync-service.ts. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { performSync } from '../../../services/sync-service.js'; + +export function createSyncHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remote } = req.body as { + worktreePath: string; + remote?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + const result = await performSync(worktreePath, { remote }); + + if (!result.success) { + const statusCode = result.hasConflicts ? 409 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, + conflictSource: result.conflictSource, + pulled: result.pulled, + pushed: result.pushed, + }); + return; + } + + res.json({ + success: true, + result: { + branch: result.branch, + pulled: result.pulled, + pushed: result.pushed, + isFastForward: result.isFastForward, + isMerge: result.isMerge, + autoResolved: result.autoResolved, + message: result.message, + }, + }); + } catch (error) { + logError(error, 'Sync worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/update-pr-number.ts b/apps/server/src/routes/worktree/routes/update-pr-number.ts new file mode 100644 index 00000000..b39508f9 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/update-pr-number.ts @@ -0,0 +1,163 @@ +/** + * POST /update-pr-number endpoint - Update the tracked PR number for a worktree + * + * Allows users to manually change which PR number is tracked for a worktree branch. + * Fetches updated PR info from GitHub when available, or updates metadata with the + * provided number only if GitHub CLI is unavailable. + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError, execAsync, execEnv, isGhCliAvailable } from '../common.js'; +import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; + +const logger = createLogger('UpdatePRNumber'); + +export function createUpdatePRNumberHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, projectPath, prNumber } = req.body as { + worktreePath: string; + projectPath?: string; + prNumber: number; + }; + + if (!worktreePath) { + res.status(400).json({ success: false, error: 'worktreePath required' }); + return; + } + + if ( + !prNumber || + typeof prNumber !== 'number' || + prNumber <= 0 || + !Number.isInteger(prNumber) + ) { + res.status(400).json({ success: false, error: 'prNumber must be a positive integer' }); + return; + } + + const effectiveProjectPath = projectPath || worktreePath; + + // Get current branch name + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + env: execEnv, + }); + const branchName = branchOutput.trim(); + + if (!branchName || branchName === 'HEAD') { + res.status(400).json({ + success: false, + error: 'Cannot update PR number in detached HEAD state', + }); + return; + } + + // Try to fetch PR info from GitHub for the given PR number + const ghCliAvailable = await isGhCliAvailable(); + + if (ghCliAvailable) { + try { + // Detect repository for gh CLI + let repoFlag = ''; + try { + const { stdout: remotes } = await execAsync('git remote -v', { + cwd: worktreePath, + env: execEnv, + }); + const lines = remotes.split(/\r?\n/); + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + let originRepo: string | null = null; + + for (const line of lines) { + const match = + line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); + + if (match) { + const [, remoteName, owner, repo] = match; + if (remoteName === 'upstream') { + upstreamRepo = `${owner}/${repo}`; + } else if (remoteName === 'origin') { + originOwner = owner; + originRepo = repo; + } + } + } + + const targetRepo = + upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null); + if (targetRepo) { + repoFlag = ` --repo "${targetRepo}"`; + } + } catch { + // Ignore remote parsing errors + } + + // Fetch PR info from GitHub using the PR number + const viewCmd = `gh pr view ${prNumber}${repoFlag} --json number,title,url,state,createdAt`; + const { stdout: prOutput } = await execAsync(viewCmd, { + cwd: worktreePath, + env: execEnv, + }); + + const prData = JSON.parse(prOutput); + + const prInfo = { + number: prData.number, + url: prData.url, + title: prData.title, + state: validatePRState(prData.state), + createdAt: prData.createdAt || new Date().toISOString(), + }; + + await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo); + + logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName}`); + + res.json({ + success: true, + result: { + branch: branchName, + prInfo, + }, + }); + return; + } catch (error) { + logger.warn(`Failed to fetch PR #${prNumber} from GitHub:`, error); + // Fall through to simple update below + } + } + + // Fallback: update with just the number, preserving existing PR info structure + // or creating minimal info if no GitHub data available + const prInfo = { + number: prNumber, + url: `https://github.com/pulls/${prNumber}`, + title: `PR #${prNumber}`, + state: validatePRState('OPEN'), + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo); + + logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName} (no GitHub data)`); + + res.json({ + success: true, + result: { + branch: branchName, + prInfo, + ghCliUnavailable: !ghCliAvailable, + }, + }); + } catch (error) { + logError(error, 'Update PR number failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts new file mode 100644 index 00000000..4e5b874c --- /dev/null +++ b/apps/server/src/routes/zai/index.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express'; +import { ZaiUsageService } from '../../services/zai-usage-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Zai'); + +export function createZaiRoutes( + usageService: ZaiUsageService, + settingsService: SettingsService +): Router { + const router = Router(); + + // Initialize z.ai API token from credentials on startup + (async () => { + try { + const credentials = await settingsService.getCredentials(); + if (credentials.apiKeys?.zai) { + usageService.setApiToken(credentials.apiKeys.zai); + logger.info('[init] Loaded z.ai API key from credentials'); + } + } catch (error) { + logger.error('[init] Failed to load z.ai API key from credentials:', error); + } + })(); + + // Get current usage (fetches from z.ai API) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if z.ai API is configured + const isAvailable = usageService.isAvailable(); + if (!isAvailable) { + // Use a 200 + error payload so the UI doesn't interpret it as session auth error + res.status(200).json({ + error: 'z.ai API not configured', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not configured') || message.includes('API token')) { + res.status(200).json({ + error: 'API token required', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + } else if (message.includes('failed') || message.includes('request')) { + res.status(200).json({ + error: 'API request failed', + message: message, + }); + } else { + logger.error('Error fetching z.ai usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Configure API token (for settings page) + router.post('/configure', async (req: Request, res: Response) => { + try { + const { apiToken, apiHost } = req.body; + + // Validate apiToken: must be present and a string + if (apiToken === undefined || apiToken === null || typeof apiToken !== 'string') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiToken is required and must be a string', + }); + return; + } + + // Validate apiHost if provided: must be a string and a well-formed URL + if (apiHost !== undefined && apiHost !== null) { + if (typeof apiHost !== 'string') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a string', + }); + return; + } + // Validate that apiHost is a well-formed URL + try { + const parsedUrl = new URL(apiHost); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a valid HTTP or HTTPS URL', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a well-formed URL', + }); + return; + } + } + + // Pass only the sanitized values to the service + const sanitizedToken = apiToken.trim(); + const sanitizedHost = typeof apiHost === 'string' ? apiHost.trim() : undefined; + + const result = await usageService.configure( + { apiToken: sanitizedToken, apiHost: sanitizedHost }, + settingsService + ); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error configuring z.ai:', error); + res.status(500).json({ error: message }); + } + }); + + // Verify API key without storing it (for testing in settings) + router.post('/verify', async (req: Request, res: Response) => { + try { + const { apiKey } = req.body; + const result = await usageService.verifyApiKey(apiKey); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + res.json({ + success: false, + authenticated: false, + error: `Network error: ${message}`, + }); + } + }); + + // Check if z.ai is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const isAvailable = usageService.isAvailable(); + const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY); + const hasApiKey = usageService.getApiToken() !== null; + + res.json({ + success: true, + available: isAvailable, + hasApiKey, + hasEnvApiKey, + message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/agent-executor-types.ts b/apps/server/src/services/agent-executor-types.ts new file mode 100644 index 00000000..e84964a1 --- /dev/null +++ b/apps/server/src/services/agent-executor-types.ts @@ -0,0 +1,87 @@ +/** + * AgentExecutor Types - Type definitions for agent execution + */ + +import type { + PlanningMode, + ThinkingLevel, + ReasoningEffort, + ParsedTask, + ClaudeCompatibleProvider, + Credentials, +} from '@automaker/types'; +import type { BaseProvider } from '../providers/base-provider.js'; + +export interface AgentExecutionOptions { + workDir: string; + featureId: string; + prompt: string; + projectPath: string; + abortController: AbortController; + imagePaths?: string[]; + model?: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + branchName?: string | null; + credentials?: Credentials; + claudeCompatibleProvider?: ClaudeCompatibleProvider; + mcpServers?: Record; + sdkSessionId?: string; + sdkOptions?: { + maxTurns?: number; + allowedTools?: string[]; + systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; + settingSources?: Array<'user' | 'project' | 'local'>; + }; + provider: BaseProvider; + effectiveBareModel: string; + specAlreadyDetected?: boolean; + existingApprovedPlanContent?: string; + persistedTasks?: ParsedTask[]; +} + +export interface AgentExecutionResult { + responseText: string; + specDetected: boolean; + tasksCompleted: number; + aborted: boolean; +} + +export type WaitForApprovalFn = ( + featureId: string, + projectPath: string +) => Promise<{ approved: boolean; feedback?: string; editedPlan?: string }>; + +export type SaveFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +export type UpdateFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +export type BuildTaskPromptFn = ( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + planContent: string, + taskPromptTemplate: string, + userFeedback?: string +) => string; + +export interface AgentExecutorCallbacks { + waitForApproval: WaitForApprovalFn; + saveFeatureSummary: SaveFeatureSummaryFn; + updateFeatureSummary: UpdateFeatureSummaryFn; + buildTaskPrompt: BuildTaskPromptFn; +} diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts new file mode 100644 index 00000000..f7930766 --- /dev/null +++ b/apps/server/src/services/agent-executor.ts @@ -0,0 +1,798 @@ +/** + * AgentExecutor - Core agent execution engine with streaming support + */ + +import path from 'path'; +import type { ExecuteOptions, ParsedTask } from '@automaker/types'; +import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import { TypedEventBus } from './typed-event-bus.js'; +import { FeatureStateManager } from './feature-state-manager.js'; +import { PlanApprovalService } from './plan-approval-service.js'; +import type { SettingsService } from './settings-service.js'; +import { + parseTasksFromSpec, + detectTaskStartMarker, + detectTaskCompleteMarker, + detectPhaseCompleteMarker, + detectSpecFallback, + extractSummary, +} from './spec-parser.js'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; +import type { + AgentExecutionOptions, + AgentExecutionResult, + AgentExecutorCallbacks, +} from './agent-executor-types.js'; + +// Re-export types for backward compatibility +export type { + AgentExecutionOptions, + AgentExecutionResult, + WaitForApprovalFn, + SaveFeatureSummaryFn, + UpdateFeatureSummaryFn, + BuildTaskPromptFn, +} from './agent-executor-types.js'; + +const logger = createLogger('AgentExecutor'); + +const DEFAULT_MAX_TURNS = 10000; + +export class AgentExecutor { + private static readonly WRITE_DEBOUNCE_MS = 500; + private static readonly STREAM_HEARTBEAT_MS = 15_000; + + /** + * Sanitize a provider error value into clean text. + * Coalesces to string, removes ANSI codes, strips leading "Error:" prefix, + * trims, and returns 'Unknown error' when empty. + */ + private static sanitizeProviderError(input: string | { error?: string } | undefined): string { + let raw: string; + if (typeof input === 'string') { + raw = input; + } else if (input && typeof input === 'object' && typeof input.error === 'string') { + raw = input.error; + } else { + raw = ''; + } + const cleaned = raw + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim(); + return cleaned || 'Unknown error'; + } + + constructor( + private eventBus: TypedEventBus, + private featureStateManager: FeatureStateManager, + private planApprovalService: PlanApprovalService, + private settingsService: SettingsService | null = null + ) {} + + async execute( + options: AgentExecutionOptions, + callbacks: AgentExecutorCallbacks + ): Promise { + const { + workDir, + featureId, + projectPath, + abortController, + branchName = null, + provider, + effectiveBareModel, + previousContent, + planningMode = 'skip', + requirePlanApproval = false, + specAlreadyDetected = false, + existingApprovedPlanContent, + persistedTasks, + credentials, + claudeCompatibleProvider, + mcpServers, + sdkSessionId, + sdkOptions, + } = options; + const { content: promptContent } = await buildPromptWithImages( + options.prompt, + options.imagePaths, + workDir, + false + ); + const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; + if (sdkOptions?.maxTurns == null) { + logger.info( + `[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` + + `Model: ${effectiveBareModel}` + ); + } else { + logger.info( + `[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}` + ); + } + + const executeOptions: ExecuteOptions = { + prompt: promptContent, + model: effectiveBareModel, + maxTurns: resolvedMaxTurns, + cwd: workDir, + allowedTools: sdkOptions?.allowedTools as string[] | undefined, + abortController, + systemPrompt: sdkOptions?.systemPrompt, + settingSources: sdkOptions?.settingSources, + mcpServers: + mcpServers && Object.keys(mcpServers).length > 0 + ? (mcpServers as Record) + : undefined, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + credentials, + claudeCompatibleProvider, + sdkSessionId, + }; + const featureDirForOutput = getFeatureDir(projectPath, featureId); + const outputPath = path.join(featureDirForOutput, 'agent-output.md'); + const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); + const enableRawOutput = + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; + let responseText = previousContent + ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` + : ''; + let specDetected = specAlreadyDetected, + tasksCompleted = 0, + aborted = false; + let writeTimeout: ReturnType | null = null, + rawOutputLines: string[] = [], + rawWriteTimeout: ReturnType | null = null; + + const writeToFile = async (): Promise => { + try { + await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); + await secureFs.writeFile(outputPath, responseText); + } catch (error) { + logger.error(`Failed to write agent output for ${featureId}:`, error); + } + }; + const scheduleWrite = (): void => { + if (writeTimeout) clearTimeout(writeTimeout); + writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS); + }; + const appendRawEvent = (event: unknown): void => { + if (!enableRawOutput) return; + try { + rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event })); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + rawWriteTimeout = setTimeout(async () => { + try { + await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); + await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); + rawOutputLines = []; + } catch { + /* ignore */ + } + }, AgentExecutor.WRITE_DEBOUNCE_MS); + } catch { + /* ignore */ + } + }; + + const streamStartTime = Date.now(); + let receivedAnyStreamMessage = false; + const streamHeartbeat = setInterval(() => { + if (!receivedAnyStreamMessage) + logger.info( + `Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...` + ); + }, AgentExecutor.STREAM_HEARTBEAT_MS); + const planningModeRequiresApproval = + planningMode === 'spec' || + planningMode === 'full' || + (planningMode === 'lite' && requirePlanApproval); + const requiresApproval = planningModeRequiresApproval && requirePlanApproval; + + if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) { + const result = await this.executeTasksLoop( + options, + persistedTasks, + existingApprovedPlanContent, + responseText, + scheduleWrite, + callbacks + ); + clearInterval(streamHeartbeat); + if (writeTimeout) clearTimeout(writeTimeout); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + await writeToFile(); + return { + responseText: result.responseText, + specDetected: true, + tasksCompleted: result.tasksCompleted, + aborted: result.aborted, + }; + } + + logger.info(`Starting stream for feature ${featureId}...`); + + try { + const stream = provider.executeQuery(executeOptions); + streamLoop: for await (const msg of stream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + receivedAnyStreamMessage = true; + appendRawEvent(msg); + if (abortController.signal.aborted) { + aborted = true; + throw new Error('Feature execution aborted'); + } + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + const newText = block.text || ''; + if (!newText) continue; + if (responseText.length > 0 && newText.length > 0) { + const endsWithSentence = /[.!?:]\s*$/.test(responseText), + endsWithNewline = /\n\s*$/.test(responseText); + if ( + !endsWithNewline && + (endsWithSentence || /^[\n#\-*>]/.test(newText)) && + !/[a-zA-Z0-9]/.test(responseText.slice(-1)) + ) + responseText += '\n\n'; + } + responseText += newText; + // Check for authentication errors using provider-agnostic utility + if (block.text && isAuthenticationError(block.text)) + throw new Error( + 'Authentication failed: Invalid or expired API key. Please check your API key configuration or re-authenticate with your provider.' + ); + scheduleWrite(); + const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'), + hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); + if ( + planningModeRequiresApproval && + !specDetected && + (hasExplicitMarker || hasFallbackSpec) + ) { + specDetected = true; + const planContent = hasExplicitMarker + ? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim() + : responseText.trim(); + if (!hasExplicitMarker) + logger.info(`Using fallback spec detection for feature ${featureId}`); + const result = await this.handleSpecGenerated( + options, + planContent, + responseText, + requiresApproval, + scheduleWrite, + callbacks + ); + responseText = result.responseText; + tasksCompleted = result.tasksCompleted; + break streamLoop; + } + if (!specDetected) + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: block.text, + }); + } else if (block.type === 'tool_use') { + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: block.name, + input: block.input, + }); + if (responseText.length > 0 && !responseText.endsWith('\n')) responseText += '\n'; + responseText += `\n🔧 Tool: ${block.name}\n`; + if (block.input) responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + scheduleWrite(); + } + } + } else if (msg.type === 'error') { + const sanitized = AgentExecutor.sanitizeProviderError(msg.error); + logger.error( + `[execute] Feature ${featureId} received error from provider. ` + + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(sanitized); + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + scheduleWrite(); + } else if (msg.subtype?.startsWith('error')) { + // Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.) + logger.error( + `[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` + + `session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(`Agent execution ended with: ${msg.subtype}`); + } else { + logger.warn( + `[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}` + ); + } + } + } + } finally { + clearInterval(streamHeartbeat); + if (writeTimeout) clearTimeout(writeTimeout); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + + const streamElapsedMs = Date.now() - streamStartTime; + logger.info( + `[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` + + `aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}` + ); + + await writeToFile(); + if (enableRawOutput && rawOutputLines.length > 0) { + try { + await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); + await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); + } catch { + /* ignore */ + } + } + } + return { responseText, specDetected, tasksCompleted, aborted }; + } + + private async executeTasksLoop( + options: AgentExecutionOptions, + tasks: ParsedTask[], + planContent: string, + initialResponseText: string, + scheduleWrite: () => void, + callbacks: AgentExecutorCallbacks, + userFeedback?: string + ): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> { + const { + featureId, + projectPath, + abortController, + branchName = null, + provider, + sdkOptions, + } = options; + logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`); + const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + let responseText = initialResponseText, + tasksCompleted = 0; + + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + const task = tasks[taskIndex]; + if (task.status === 'completed') { + tasksCompleted++; + continue; + } + if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true }; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + task.id, + 'in_progress' + ); + this.eventBus.emitAutoModeEvent('auto_mode_task_started', { + featureId, + projectPath, + branchName, + taskId: task.id, + taskDescription: task.description, + taskIndex, + tasksTotal: tasks.length, + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + currentTaskId: task.id, + }); + const taskPrompt = callbacks.buildTaskPrompt( + task, + tasks, + taskIndex, + planContent, + taskPrompts.taskExecution.taskPromptTemplate, + userFeedback + ); + const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; + logger.info( + `[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` + + `maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})` + ); + const taskStream = provider.executeQuery( + this.buildExecOpts(options, taskPrompt, taskMaxTurns) + ); + let taskOutput = '', + taskStartDetected = false, + taskCompleteDetected = false; + + for await (const msg of taskStream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) { + for (const b of msg.message.content) { + if (b.type === 'text') { + const text = b.text || ''; + taskOutput += text; + responseText += text; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: text, + }); + scheduleWrite(); + if (!taskStartDetected) { + const sid = detectTaskStartMarker(taskOutput); + if (sid) { + taskStartDetected = true; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + sid, + 'in_progress' + ); + } + } + if (!taskCompleteDetected) { + const cid = detectTaskCompleteMarker(taskOutput); + if (cid) { + taskCompleteDetected = true; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + cid, + 'completed' + ); + } + } + const pn = detectPhaseCompleteMarker(text); + if (pn !== null) + this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { + featureId, + projectPath, + branchName, + phaseNumber: pn, + }); + } else if (b.type === 'tool_use') + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: b.name, + input: b.input, + }); + } + } else if (msg.type === 'error') { + const fallback = `Error during task ${task.id}`; + const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback); + logger.error( + `[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` + + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(sanitized); + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + taskOutput += msg.result || ''; + responseText += msg.result || ''; + } else if (msg.subtype?.startsWith('error')) { + logger.error( + `[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` + + `session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(`Agent execution ended with: ${msg.subtype}`); + } else { + logger.warn( + `[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}` + ); + } + } + } + if (!taskCompleteDetected) + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + task.id, + 'completed' + ); + tasksCompleted = taskIndex + 1; + this.eventBus.emitAutoModeEvent('auto_mode_task_complete', { + featureId, + projectPath, + branchName, + taskId: task.id, + tasksCompleted, + tasksTotal: tasks.length, + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + tasksCompleted, + }); + if (task.phase) { + const next = tasks[taskIndex + 1]; + if (!next || next.phase !== task.phase) { + const m = task.phase.match(/Phase\s*(\d+)/i); + if (m) + this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { + featureId, + projectPath, + branchName, + phaseNumber: parseInt(m[1], 10), + }); + } + } + } + const summary = extractSummary(responseText); + if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary); + return { responseText, tasksCompleted, aborted: false }; + } + + private async handleSpecGenerated( + options: AgentExecutionOptions, + planContent: string, + initialResponseText: string, + requiresApproval: boolean, + scheduleWrite: () => void, + callbacks: AgentExecutorCallbacks + ): Promise<{ responseText: string; tasksCompleted: number }> { + const { + featureId, + projectPath, + branchName = null, + planningMode = 'skip', + provider, + sdkOptions, + } = options; + let responseText = initialResponseText, + parsedTasks = parseTasksFromSpec(planContent); + logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: planContent, + version: 1, + generatedAt: new Date().toISOString(), + reviewedByUser: false, + tasks: parsedTasks, + tasksTotal: parsedTasks.length, + tasksCompleted: 0, + }); + const planSummary = extractSummary(planContent); + if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary); + let approvedPlanContent = planContent, + userFeedback: string | undefined, + currentPlanContent = planContent, + planVersion = 1; + + if (requiresApproval) { + let planApproved = false; + while (!planApproved) { + logger.info( + `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` + ); + this.eventBus.emitAutoModeEvent('plan_approval_required', { + featureId, + projectPath, + branchName, + planContent: currentPlanContent, + planningMode, + planVersion, + }); + const approvalResult = await callbacks.waitForApproval(featureId, projectPath); + if (approvalResult.approved) { + planApproved = true; + userFeedback = approvalResult.feedback; + approvedPlanContent = approvalResult.editedPlan || currentPlanContent; + if (approvalResult.editedPlan) { + // Re-parse tasks from edited plan to ensure we execute the updated tasks + const editedTasks = parseTasksFromSpec(approvalResult.editedPlan); + parsedTasks = editedTasks; + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + content: approvalResult.editedPlan, + tasks: editedTasks, + tasksTotal: editedTasks.length, + tasksCompleted: 0, + }); + } + this.eventBus.emitAutoModeEvent('plan_approved', { + featureId, + projectPath, + branchName, + hasEdits: !!approvalResult.editedPlan, + planVersion, + }); + } else { + const hasFeedback = approvalResult.feedback?.trim().length, + hasEdits = approvalResult.editedPlan?.trim().length; + if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user'); + planVersion++; + this.eventBus.emitAutoModeEvent('plan_revision_requested', { + featureId, + projectPath, + branchName, + feedback: approvalResult.feedback, + hasEdits: !!hasEdits, + planVersion, + }); + const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const taskEx = + planningMode === 'full' + ? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```' + : '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```'; + let revPrompt = revPrompts.taskExecution.planRevisionTemplate + .replace(/\{\{planVersion\}\}/g, String(planVersion - 1)) + .replace( + /\{\{previousPlan\}\}/g, + hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent + ) + .replace( + /\{\{userFeedback\}\}/g, + approvalResult.feedback || 'Please revise the plan based on the edits above.' + ) + .replace(/\{\{planningMode\}\}/g, planningMode) + .replace(/\{\{taskFormatExample\}\}/g, taskEx); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generating', + version: planVersion, + }); + let revText = ''; + for await (const msg of provider.executeQuery( + this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) + )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) + for (const b of msg.message.content) + if (b.type === 'text') { + revText += b.text || ''; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: b.text, + }); + } + if (msg.type === 'error') { + const cleanedError = + (msg.error || 'Error during plan revision') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Error during plan revision'; + throw new Error(cleanedError); + } + if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || ''; + } + const mi = revText.indexOf('[SPEC_GENERATED]'); + currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim(); + const revisedTasks = parseTasksFromSpec(currentPlanContent); + if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full')) + this.eventBus.emitAutoModeEvent('plan_revision_warning', { + featureId, + projectPath, + branchName, + planningMode, + warning: 'Revised plan missing tasks block', + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: currentPlanContent, + version: planVersion, + tasks: revisedTasks, + tasksTotal: revisedTasks.length, + tasksCompleted: 0, + }); + parsedTasks = revisedTasks; + responseText += revText; + } + } + } else { + this.eventBus.emitAutoModeEvent('plan_auto_approved', { + featureId, + projectPath, + branchName, + planContent, + planningMode, + }); + } + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: requiresApproval, + }); + let tasksCompleted = 0; + if (parsedTasks.length > 0) { + const r = await this.executeTasksLoop( + options, + parsedTasks, + approvedPlanContent, + responseText, + scheduleWrite, + callbacks, + userFeedback + ); + responseText = r.responseText; + tasksCompleted = r.tasksCompleted; + } else { + const r = await this.executeSingleAgentContinuation( + options, + approvedPlanContent, + userFeedback, + responseText + ); + responseText = r.responseText; + } + const summary = extractSummary(responseText); + if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary); + return { responseText, tasksCompleted }; + } + + private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) { + return { + prompt, + model: o.effectiveBareModel, + maxTurns, + cwd: o.workDir, + allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, + abortController: o.abortController, + thinkingLevel: o.thinkingLevel, + reasoningEffort: o.reasoningEffort, + mcpServers: + o.mcpServers && Object.keys(o.mcpServers).length > 0 + ? (o.mcpServers as Record) + : undefined, + credentials: o.credentials, + claudeCompatibleProvider: o.claudeCompatibleProvider, + sdkSessionId: o.sdkSessionId, + }; + } + + private async executeSingleAgentContinuation( + options: AgentExecutionOptions, + planContent: string, + userFeedback: string | undefined, + initialResponseText: string + ): Promise<{ responseText: string }> { + const { featureId, branchName = null, provider } = options; + logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`); + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate + .replace(/\{\{userFeedback\}\}/g, userFeedback || '') + .replace(/\{\{approvedPlan\}\}/g, planContent); + let responseText = initialResponseText; + for await (const msg of provider.executeQuery( + this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) + )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) + for (const b of msg.message.content) { + if (b.type === 'text') { + responseText += b.text || ''; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: b.text, + }); + } else if (b.type === 'tool_use') + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: b.name, + input: b.input, + }); + } + else if (msg.type === 'error') { + const cleanedError = + (msg.error || 'Unknown error during implementation') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Unknown error during implementation'; + throw new Error(cleanedError); + } else if (msg.type === 'result' && msg.subtype === 'success') + responseText += msg.result || ''; + } + return { responseText }; + } +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 09c91979..443fff04 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -15,14 +15,13 @@ import { loadContextFiles, createLogger, classifyError, - getUserFriendlyErrorMessage, } from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; -import { PathNotAllowedError } from '@automaker/platform'; import type { SettingsService } from './settings-service.js'; import { getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, @@ -30,6 +29,7 @@ import { getSubagentsConfiguration, getCustomSubagents, getProviderByModelId, + getDefaultMaxTurnsSetting, } from '../lib/settings-helpers.js'; interface Message { @@ -98,6 +98,20 @@ export class AgentService { await secureFs.mkdir(this.stateDir, { recursive: true }); } + /** + * Detect provider-side session errors (session not found, expired, etc.). + * Used to decide whether to clear a stale sdkSessionId. + */ + private isStaleSessionError(rawErrorText: string): boolean { + const errorLower = rawErrorText.toLowerCase(); + return ( + errorLower.includes('session not found') || + errorLower.includes('session expired') || + errorLower.includes('invalid session') || + errorLower.includes('no such session') + ); + } + /** * Start or resume a conversation */ @@ -108,32 +122,26 @@ export class AgentService { sessionId: string; workingDirectory?: string; }) { - if (!this.sessions.has(sessionId)) { - const messages = await this.loadSession(sessionId); - const metadata = await this.loadMetadata(); - const sessionMetadata = metadata[sessionId]; - - // Determine the effective working directory + // ensureSession handles loading from disk if not in memory. + // For startConversation, we always want to create a session even if + // metadata doesn't exist yet (new session), so we fall back to creating one. + let session = await this.ensureSession(sessionId, workingDirectory); + if (!session) { + // Session doesn't exist on disk either — create a fresh in-memory session. const effectiveWorkingDirectory = workingDirectory || process.cwd(); const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); - - // Validate that the working directory is allowed using centralized validation validateWorkingDirectory(resolvedWorkingDirectory); - // Load persisted queue - const promptQueue = await this.loadQueueState(sessionId); - - this.sessions.set(sessionId, { - messages, + session = { + messages: [], isRunning: false, abortController: null, workingDirectory: resolvedWorkingDirectory, - sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID - promptQueue, - }); + promptQueue: [], + }; + this.sessions.set(sessionId, session); } - const session = this.sessions.get(sessionId)!; return { success: true, messages: session.messages, @@ -141,6 +149,98 @@ export class AgentService { }; } + /** + * Ensure a session is loaded into memory. + * + * Sessions may exist on disk (in metadata and session files) but not be + * present in the in-memory Map — for example after a server restart, or + * when a client calls sendMessage before explicitly calling startConversation. + * + * This helper transparently loads the session from disk when it is missing + * from memory, eliminating "session not found" errors for sessions that + * were previously created but not yet initialized in memory. + * + * If both metadata and session files are missing, the session truly doesn't + * exist. A detailed diagnostic log is emitted so developers can track down + * how the invalid session ID was generated. + * + * @returns The in-memory Session object, or null if the session doesn't exist at all + */ + private async ensureSession( + sessionId: string, + workingDirectory?: string + ): Promise { + const existing = this.sessions.get(sessionId); + if (existing) { + return existing; + } + + // Try to load from disk — the session may have been created earlier + // (e.g. via createSession) but never initialized in memory. + let metadata: Record; + let messages: Message[]; + try { + [metadata, messages] = await Promise.all([this.loadMetadata(), this.loadSession(sessionId)]); + } catch (error) { + // Disk read failure should not be treated as "session not found" — + // it's a transient I/O problem. Log and return null so callers can + // surface an appropriate error message. + this.logger.error( + `Failed to load session ${sessionId} from disk (I/O error — NOT a missing session):`, + error + ); + return null; + } + + const sessionMetadata = metadata[sessionId]; + + // If there's no metadata AND no persisted messages, the session truly doesn't exist. + // Log diagnostic info to help track down how we ended up with an invalid session ID. + if (!sessionMetadata && messages.length === 0) { + this.logger.warn( + `Session "${sessionId}" not found: no metadata and no persisted messages. ` + + `This can happen when a session ID references a deleted/expired session, ` + + `or when the server restarted and the session was never persisted to disk. ` + + `Available session IDs in metadata: [${Object.keys(metadata).slice(0, 10).join(', ')}${Object.keys(metadata).length > 10 ? '...' : ''}]` + ); + return null; + } + + const effectiveWorkingDirectory = + workingDirectory || sessionMetadata?.workingDirectory || process.cwd(); + const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); + + // Validate that the working directory is allowed using centralized validation + try { + validateWorkingDirectory(resolvedWorkingDirectory); + } catch (validationError) { + this.logger.warn( + `Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` + + `returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}` + ); + return null; + } + + // Load persisted queue + const promptQueue = await this.loadQueueState(sessionId); + + const session: Session = { + messages, + isRunning: false, + abortController: null, + workingDirectory: resolvedWorkingDirectory, + sdkSessionId: sessionMetadata?.sdkSessionId, + promptQueue, + }; + + this.sessions.set(sessionId, session); + this.logger.info( + `Auto-initialized session ${sessionId} from disk ` + + `(${messages.length} messages, sdkSessionId: ${sessionMetadata?.sdkSessionId ? 'present' : 'none'})` + ); + return session; + } + /** * Send a message to the agent and stream responses */ @@ -161,10 +261,18 @@ export class AgentService { thinkingLevel?: ThinkingLevel; reasoningEffort?: ReasoningEffort; }) { - const session = this.sessions.get(sessionId); + const session = await this.ensureSession(sessionId, workingDirectory); if (!session) { - this.logger.error('ERROR: Session not found:', sessionId); - throw new Error(`Session ${sessionId} not found`); + this.logger.error( + `Session not found: ${sessionId}. ` + + `The session may have been deleted, never created, or lost after a server restart. ` + + `In-memory sessions: ${this.sessions.size}, requested ID: ${sessionId}` + ); + throw new Error( + `Session ${sessionId} not found. ` + + `The session may have been deleted or expired. ` + + `Please create a new session and try again.` + ); } if (session.isRunning) { @@ -222,12 +330,6 @@ export class AgentService { timestamp: new Date().toISOString(), }; - // Build conversation history from existing messages BEFORE adding current message - const conversationHistory = session.messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); @@ -256,6 +358,22 @@ export class AgentService { '[AgentService]' ); + // Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global) + // Wrap in try/catch so transient settingsService errors don't abort message processing + let useClaudeCodeSystemPrompt = true; + try { + useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + effectiveWorkDir, + this.settingsService, + '[AgentService]' + ); + } catch (err) { + this.logger.error( + '[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true', + err + ); + } + // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); @@ -299,6 +417,7 @@ export class AgentService { } } + let combinedSystemPrompt: string | undefined; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -316,7 +435,7 @@ export class AgentService { // Build combined system prompt with base prompt and context files const baseSystemPrompt = await this.getSystemPrompt(); - const combinedSystemPrompt = contextFilesPrompt + combinedSystemPrompt = contextFilesPrompt ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` : baseSystemPrompt; @@ -325,11 +444,15 @@ export class AgentService { const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort; - // When using a provider model, use the resolved Claude model (from mapsToClaudeModel) - // e.g., "GLM-4.5-Air" -> "claude-haiku-4-5" + // When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config + // (thinking level budgets, allowedTools) but we MUST pass the provider's model ID + // (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-6" which causes "model not found" const modelForSdk = providerResolvedModel || model; const sessionModelForSdk = providerResolvedModel ? undefined : session.model; + // Read user-configured max turns from settings + const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]'); + const sdkOptions = createChatOptions({ cwd: effectiveWorkDir, model: modelForSdk, @@ -337,7 +460,9 @@ export class AgentService { systemPrompt: combinedSystemPrompt, abortController: session.abortController!, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models + maxTurns: userMaxTurns, // User-configured max turns from settings mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, }); @@ -362,7 +487,19 @@ export class AgentService { Object.keys(customSubagents).length > 0; // Base tools that match the provider's default set - const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + const baseTools = [ + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'Glob', + 'Grep', + 'LS', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + ]; if (allowedTools) { allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options @@ -387,12 +524,28 @@ export class AgentService { } // Get provider for this model (with prefix) - const provider = ProviderFactory.getProviderForModel(effectiveModel); + // When using custom provider (GLM, MiniMax), requestedModel routes to Claude provider + const modelForProvider = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) + : effectiveModel; + const provider = ProviderFactory.getProviderForModel(modelForProvider); // Strip provider prefix - providers should receive bare model IDs - const bareModel = stripProviderPrefix(effectiveModel); + // 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 bareModel: string = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) + : stripProviderPrefix(effectiveModel); // Build options for provider + const conversationHistory = session.messages + .slice(0, -1) + .map((msg) => ({ + role: msg.role, + content: msg.content, + })) + .filter((msg) => msg.content.trim().length > 0); + const options: ExecuteOptions = { prompt: '', // Will be set below based on images model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") @@ -402,7 +555,8 @@ export class AgentService { maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + conversationHistory: + conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined, settingSources: settingSources.length > 0 ? settingSources : undefined, sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration @@ -430,10 +584,16 @@ export class AgentService { let currentAssistantMessage: Message | null = null; let responseText = ''; const toolUses: Array<{ name: string; input: unknown }> = []; + const toolNamesById = new Map(); for await (const msg of stream) { - // Capture SDK session ID from any message and persist it - if (msg.session_id && !session.sdkSessionId) { + // Capture SDK session ID from any message and persist it. + // Update when: + // - No session ID set yet (first message in a new session) + // - The provider returned a *different* session ID (e.g., after a + // "Session not found" recovery where the provider started a fresh + // session — the stale ID must be replaced with the new one) + if (msg.session_id && msg.session_id !== session.sdkSessionId) { session.sdkSessionId = msg.session_id; // Persist the SDK session ID to ensure conversation continuity across server restarts await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); @@ -469,11 +629,50 @@ export class AgentService { input: block.input, }; toolUses.push(toolUse); + if (block.tool_use_id) { + toolNamesById.set(block.tool_use_id, toolUse.name); + } this.emitAgentEvent(sessionId, { type: 'tool_use', tool: toolUse, }); + } else if (block.type === 'tool_result') { + const toolUseId = block.tool_use_id; + const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined; + + // Normalize block.content to a string for the emitted event + const rawContent: unknown = block.content; + let contentString: string; + if (typeof rawContent === 'string') { + contentString = rawContent; + } else if (Array.isArray(rawContent)) { + // Extract text from content blocks (TextBlock, ImageBlock, etc.) + contentString = rawContent + .map((part: { text?: string; type?: string }) => { + if (typeof part === 'string') return part; + if (part.text) return part.text; + // For non-text blocks (e.g., images), represent as type indicator + if (part.type) return `[${part.type}]`; + return JSON.stringify(part); + }) + .join('\n'); + } else if (rawContent !== undefined && rawContent !== null) { + contentString = JSON.stringify(rawContent); + } else { + contentString = ''; + } + + this.emitAgentEvent(sessionId, { + type: 'tool_result', + tool: { + name: toolName || 'unknown', + input: { + toolUseId, + content: contentString, + }, + }, + }); } } } @@ -496,12 +695,36 @@ export class AgentService { // streamed error messages instead of throwing. Handle these here so the // Agent Runner UX matches the Claude/Cursor behavior without changing // their provider implementations. - const rawErrorText = + + // Clean error text: strip ANSI escape codes and the redundant "Error: " + // prefix that CLI providers (especially OpenCode) add to stderr output. + // The OpenCode provider strips these in normalizeEvent/executeQuery, but + // we also strip here as a defense-in-depth measure. + // + // Without stripping the "Error: " prefix, the wrapping at line ~647 + // (`content: \`Error: ${enhancedText}\``) produces double-prefixed text: + // "Error: Error: Session not found" — confusing for the user. + const rawMsgError = (typeof msg.error === 'string' && msg.error.trim()) || 'Unexpected error from provider during agent execution.'; + let rawErrorText = rawMsgError.replace(/\x1b\[[0-9;]*m/g, '').trim() || rawMsgError; + // Remove the CLI's "Error: " prefix to prevent double-wrapping + rawErrorText = rawErrorText.replace(/^Error:\s*/i, '').trim() || rawErrorText; const errorInfo = classifyError(new Error(rawErrorText)); + // Detect provider-side session errors and proactively clear the stale + // sdkSessionId so the next attempt starts a fresh provider session. + // This handles providers that don't have built-in session recovery + // (unlike OpenCode which auto-retries without the session flag). + if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) { + this.logger.info( + `Clearing stale sdkSessionId for session ${sessionId} after provider session error` + ); + session.sdkSessionId = undefined; + await this.clearSdkSessionId(sessionId); + } + // Keep the provider-supplied text intact (Codex already includes helpful tips), // only add a small rate-limit hint when we can detect it. const enhancedText = errorInfo.isRateLimit @@ -562,13 +785,30 @@ export class AgentService { this.logger.error('Error:', error); + // Strip ANSI escape codes and the "Error: " prefix from thrown error + // messages so the UI receives clean text without double-prefixing. + let rawThrownMsg = ((error as Error).message || '').replace(/\x1b\[[0-9;]*m/g, '').trim(); + rawThrownMsg = rawThrownMsg.replace(/^Error:\s*/i, '').trim() || rawThrownMsg; + const thrownErrorMsg = rawThrownMsg.toLowerCase(); + + // Check if the thrown error is a provider-side session error. + // Clear the stale sdkSessionId so the next retry starts fresh. + if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) { + this.logger.info( + `Clearing stale sdkSessionId for session ${sessionId} after thrown session error` + ); + session.sdkSessionId = undefined; + await this.clearSdkSessionId(sessionId); + } + session.isRunning = false; session.abortController = null; + const cleanErrorMsg = rawThrownMsg || (error as Error).message; const errorMessage: Message = { id: this.generateId(), role: 'assistant', - content: `Error: ${(error as Error).message}`, + content: `Error: ${cleanErrorMsg}`, timestamp: new Date().toISOString(), isError: true, }; @@ -578,7 +818,7 @@ export class AgentService { this.emitAgentEvent(sessionId, { type: 'error', - error: (error as Error).message, + error: cleanErrorMsg, message: errorMessage, }); @@ -589,8 +829,8 @@ export class AgentService { /** * Get conversation history */ - getHistory(sessionId: string) { - const session = this.sessions.get(sessionId); + async getHistory(sessionId: string) { + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -606,7 +846,7 @@ export class AgentService { * Stop current agent execution */ async stopExecution(sessionId: string) { - const session = this.sessions.get(sessionId); + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -628,9 +868,16 @@ export class AgentService { if (session) { session.messages = []; session.isRunning = false; + session.sdkSessionId = undefined; // Clear stale provider session ID to prevent "Session not found" errors await this.saveSession(sessionId, []); } + // Clear the sdkSessionId from persisted metadata so it doesn't get + // reloaded by ensureSession() after a server restart. + // This prevents "Session not found" errors when the provider-side session + // no longer exists (e.g., OpenCode CLI sessions expire on disk). + await this.clearSdkSessionId(sessionId); + return { success: true }; } @@ -787,6 +1034,23 @@ export class AgentService { return true; } + /** + * Clear the sdkSessionId from persisted metadata. + * + * This removes the provider-side session ID so that the next message + * starts a fresh provider session instead of trying to resume a stale one. + * Prevents "Session not found" errors from CLI providers like OpenCode + * when the provider-side session has been deleted or expired. + */ + async clearSdkSessionId(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (metadata[sessionId] && metadata[sessionId].sdkSessionId) { + delete metadata[sessionId].sdkSessionId; + metadata[sessionId].updatedAt = new Date().toISOString(); + await this.saveMetadata(metadata); + } + } + // Queue management methods /** @@ -801,7 +1065,7 @@ export class AgentService { thinkingLevel?: ThinkingLevel; } ): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> { - const session = this.sessions.get(sessionId); + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -830,8 +1094,10 @@ export class AgentService { /** * Get the current queue for a session */ - getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } { - const session = this.sessions.get(sessionId); + async getQueue( + sessionId: string + ): Promise<{ success: boolean; queue?: QueuedPrompt[]; error?: string }> { + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -845,7 +1111,7 @@ export class AgentService { sessionId: string, promptId: string ): Promise<{ success: boolean; error?: string }> { - const session = this.sessions.get(sessionId); + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -870,7 +1136,7 @@ export class AgentService { * Clear all prompts from the queue */ async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> { - const session = this.sessions.get(sessionId); + const session = await this.ensureSession(sessionId); if (!session) { return { success: false, error: 'Session not found' }; } @@ -953,10 +1219,24 @@ export class AgentService { } } + /** + * Emit an event to the agent stream (private, used internally). + */ private emitAgentEvent(sessionId: string, data: Record): void { this.events.emit('agent:stream', { sessionId, ...data }); } + /** + * Emit an error event for a session. + * + * Public method so that route handlers can surface errors to the UI + * even when sendMessage() throws before it can emit its own error event + * (e.g., when the session is not found and no in-memory session exists). + */ + emitSessionError(sessionId: string, error: string): void { + this.events.emit('agent:stream', { sessionId, type: 'error', error }); + } + private async getSystemPrompt(): Promise { // Load from settings (no caching - allows hot reload of custom prompts) const prompts = await getPromptCustomization(this.settingsService, '[AgentService]'); diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts new file mode 100644 index 00000000..6d83e699 --- /dev/null +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -0,0 +1,465 @@ +/** + * AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking + */ + +import type { Feature } from '@automaker/types'; +import { createLogger, classifyError } from '@automaker/utils'; +import { areDependenciesSatisfied } from '@automaker/dependency-resolver'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager } from './concurrency-manager.js'; +import type { SettingsService } from './settings-service.js'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; + +const logger = createLogger('AutoLoopCoordinator'); + +const CONSECUTIVE_FAILURE_THRESHOLD = 3; +const FAILURE_WINDOW_MS = 60000; + +export interface AutoModeConfig { + maxConcurrency: number; + useWorktrees: boolean; + projectPath: string; + branchName: string | null; +} + +export interface ProjectAutoLoopState { + abortController: AbortController; + config: AutoModeConfig; + isRunning: boolean; + consecutiveFailures: { timestamp: number; error: string }[]; + pausedDueToFailures: boolean; + hasEmittedIdleEvent: boolean; + branchName: string | null; +} + +/** + * Generate a unique key for a worktree auto-loop instance. + * + * When branchName is null, this represents the main worktree (uses '__main__' sentinel). + * The string 'main' is also normalized to '__main__' for consistency. + * Named branches always use their exact name. + */ +export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectPath}::${normalizedBranch ?? '__main__'}`; +} + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean +) => Promise; +export type LoadPendingFeaturesFn = ( + projectPath: string, + branchName: string | null +) => Promise; +export type SaveExecutionStateFn = ( + projectPath: string, + branchName: string | null, + maxConcurrency: number +) => Promise; +export type ClearExecutionStateFn = ( + projectPath: string, + branchName: string | null +) => Promise; +export type ResetStuckFeaturesFn = (projectPath: string) => Promise; +export type IsFeatureFinishedFn = (feature: Feature) => boolean; +export type LoadAllFeaturesFn = (projectPath: string) => Promise; + +export class AutoLoopCoordinator { + private autoLoopsByProject = new Map(); + + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private executeFeatureFn: ExecuteFeatureFn, + private loadPendingFeaturesFn: LoadPendingFeaturesFn, + private saveExecutionStateFn: SaveExecutionStateFn, + private clearExecutionStateFn: ClearExecutionStateFn, + private resetStuckFeaturesFn: ResetStuckFeaturesFn, + private isFeatureFinishedFn: IsFeatureFinishedFn, + private isFeatureRunningFn: (featureId: string) => boolean, + private loadAllFeaturesFn?: LoadAllFeaturesFn + ) {} + + /** + * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) + * @param projectPath - The project to start auto mode for + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) + */ + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const resolvedMaxConcurrency = await this.resolveMaxConcurrency( + projectPath, + branchName, + maxConcurrency + ); + + // Use worktree-scoped key + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + + // Check if this project/worktree already has an active autoloop + const existingState = this.autoLoopsByProject.get(worktreeKey); + if (existingState?.isRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + throw new Error( + `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` + ); + } + + // Create new project/worktree autoloop state + const abortController = new AbortController(); + const config: AutoModeConfig = { + maxConcurrency: resolvedMaxConcurrency, + useWorktrees: true, + projectPath, + branchName, + }; + + const projectState: ProjectAutoLoopState = { + abortController, + config, + isRunning: true, + consecutiveFailures: [], + pausedDueToFailures: false, + hasEmittedIdleEvent: false, + branchName, + }; + + this.autoLoopsByProject.set(worktreeKey, projectState); + try { + await this.resetStuckFeaturesFn(projectPath); + } catch { + /* ignore */ + } + this.eventBus.emitAutoModeEvent('auto_mode_started', { + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, + projectPath, + branchName, + maxConcurrency: resolvedMaxConcurrency, + }); + await this.saveExecutionStateFn(projectPath, branchName, resolvedMaxConcurrency); + this.runAutoLoopForProject(worktreeKey).catch((error) => { + const errorInfo = classifyError(error); + this.eventBus.emitAutoModeEvent('auto_mode_error', { + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + branchName, + }); + }); + return resolvedMaxConcurrency; + } + + private async runAutoLoopForProject(worktreeKey: string): Promise { + const projectState = this.autoLoopsByProject.get(worktreeKey); + if (!projectState) return; + const { projectPath, branchName } = projectState.config; + while (projectState.isRunning && !projectState.abortController.signal.aborted) { + try { + // Count ALL running features (both auto and manual) against the concurrency limit. + // This ensures auto mode is aware of the total system load and does not over-subscribe + // resources. Manual tasks always bypass the limit and run immediately, but their + // presence is accounted for when deciding whether to dispatch new auto-mode tasks. + const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); + if (runningCount >= projectState.config.maxConcurrency) { + await this.sleep(5000, projectState.abortController.signal); + continue; + } + const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName); + if (pendingFeatures.length === 0) { + if (runningCount === 0 && !projectState.hasEmittedIdleEvent) { + this.eventBus.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + } + await this.sleep(10000, projectState.abortController.signal); + continue; + } + + // Load all features for dependency checking (if callback provided) + const allFeatures = this.loadAllFeaturesFn + ? await this.loadAllFeaturesFn(projectPath) + : undefined; + + // Filter to eligible features: not running, not finished, and dependencies satisfied. + // When loadAllFeaturesFn is not provided, allFeatures is undefined and we bypass + // dependency checks (returning true) to avoid false negatives caused by completed + // features being absent from pendingFeatures. + const eligibleFeatures = pendingFeatures.filter( + (f) => + !this.isFeatureRunningFn(f.id) && + !this.isFeatureFinishedFn(f) && + (this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true) + ); + + // Sort eligible features by priority (lower number = higher priority, default 2) + eligibleFeatures.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2)); + + const nextFeature = eligibleFeatures[0] ?? null; + + if (nextFeature) { + logger.info( + `Auto-loop selected feature "${nextFeature.title || nextFeature.id}" ` + + `(priority=${nextFeature.priority ?? 2}) from ${eligibleFeatures.length} eligible features` + ); + } + if (nextFeature) { + projectState.hasEmittedIdleEvent = false; + this.executeFeatureFn( + projectPath, + nextFeature.id, + projectState.config.useWorktrees, + true + ).catch((error) => { + const errorInfo = classifyError(error); + logger.error(`Auto-loop feature ${nextFeature.id} failed:`, errorInfo.message); + if (this.trackFailureAndCheckPauseForProject(projectPath, branchName, errorInfo)) { + this.signalShouldPauseForProject(projectPath, branchName, errorInfo); + } + }); + } + await this.sleep(2000, projectState.abortController.signal); + } catch { + if (projectState.abortController.signal.aborted) break; + await this.sleep(5000, projectState.abortController.signal); + } + } + projectState.isRunning = false; + } + + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + if (!projectState) return 0; + const wasRunning = projectState.isRunning; + projectState.isRunning = false; + projectState.abortController.abort(); + await this.clearExecutionStateFn(projectPath, branchName); + if (wasRunning) + this.eventBus.emitAutoModeEvent('auto_mode_stopped', { + message: 'Auto mode stopped', + projectPath, + branchName, + }); + this.autoLoopsByProject.delete(worktreeKey); + return await this.getRunningCountForWorktree(projectPath, branchName); + } + + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + return projectState?.isRunning ?? false; + } + + /** + * Get auto loop config for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ + getAutoLoopConfigForProject( + projectPath: string, + branchName: string | null = null + ): AutoModeConfig | null { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + return projectState?.config ?? null; + } + + /** + * Get all active auto loop worktrees with their project paths and branch names + */ + getActiveWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeWorktrees.push({ + projectPath: state.config.projectPath, + branchName: state.branchName, + }); + } + } + return activeWorktrees; + } + + getActiveProjects(): string[] { + const activeProjects = new Set(); + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) activeProjects.add(state.config.projectPath); + } + return Array.from(activeProjects); + } + + /** + * Get the number of running features for a worktree. + * By default counts ALL running features (both auto-mode and manual). + * Pass `autoModeOnly: true` to count only auto-mode features. + */ + async getRunningCountForWorktree( + projectPath: string, + branchName: string | null, + options?: { autoModeOnly?: boolean } + ): Promise { + return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options); + } + + trackFailureAndCheckPauseForProject( + projectPath: string, + branchNameOrError: string | null | { type: string; message: string }, + errorInfo?: { type: string; message: string } + ): boolean { + // Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures + let branchName: string | null; + let actualErrorInfo: { type: string; message: string }; + if ( + typeof branchNameOrError === 'object' && + branchNameOrError !== null && + 'type' in branchNameOrError + ) { + // Old signature: (projectPath, errorInfo) + branchName = null; + actualErrorInfo = branchNameOrError; + } else { + // New signature: (projectPath, branchName, errorInfo) + branchName = branchNameOrError; + actualErrorInfo = errorInfo!; + } + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (!projectState) return false; + const now = Date.now(); + projectState.consecutiveFailures.push({ timestamp: now, error: actualErrorInfo.message }); + projectState.consecutiveFailures = projectState.consecutiveFailures.filter( + (f) => now - f.timestamp < FAILURE_WINDOW_MS + ); + return ( + projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD || + actualErrorInfo.type === 'quota_exhausted' || + actualErrorInfo.type === 'rate_limit' + ); + } + + signalShouldPauseForProject( + projectPath: string, + branchNameOrError: string | null | { type: string; message: string }, + errorInfo?: { type: string; message: string } + ): void { + // Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures + let branchName: string | null; + let actualErrorInfo: { type: string; message: string }; + if ( + typeof branchNameOrError === 'object' && + branchNameOrError !== null && + 'type' in branchNameOrError + ) { + branchName = null; + actualErrorInfo = branchNameOrError; + } else { + branchName = branchNameOrError; + actualErrorInfo = errorInfo!; + } + + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (!projectState || projectState.pausedDueToFailures) return; + projectState.pausedDueToFailures = true; + const failureCount = projectState.consecutiveFailures.length; + this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', { + message: + failureCount >= CONSECUTIVE_FAILURE_THRESHOLD + ? `Auto Mode paused: ${failureCount} consecutive failures detected.` + : 'Auto Mode paused: Usage limit or API error detected.', + errorType: actualErrorInfo.type, + originalError: actualErrorInfo.message, + failureCount, + projectPath, + branchName, + }); + this.stopAutoLoopForProject(projectPath, branchName); + } + + resetFailureTrackingForProject(projectPath: string, branchName: string | null = null): void { + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (projectState) { + projectState.consecutiveFailures = []; + projectState.pausedDueToFailures = false; + } + } + + recordSuccessForProject(projectPath: string, branchName: string | null = null): void { + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (projectState) projectState.consecutiveFailures = []; + } + + async resolveMaxConcurrency( + projectPath: string, + branchName: string | null, + provided?: number + ): Promise { + if (typeof provided === 'number' && Number.isFinite(provided)) return provided; + if (!this.settingsService) return DEFAULT_MAX_CONCURRENCY; + try { + const settings = await this.settingsService.getGlobalSettings(); + const globalMax = + typeof settings.maxConcurrency === 'number' + ? settings.maxConcurrency + : DEFAULT_MAX_CONCURRENCY; + const projectId = settings.projects?.find((p) => p.path === projectPath)?.id; + const autoModeByWorktree = settings.autoModeByWorktree; + if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { + // Normalize both null and 'main' to '__main__' to match the same + // canonicalization used by getWorktreeAutoLoopKey, ensuring that + // lookups for the primary branch always use the '__main__' sentinel + // regardless of whether the caller passed null or the string 'main'. + const normalizedBranch = + branchName === null || branchName === 'main' ? '__main__' : branchName; + const worktreeId = `${projectId}::${normalizedBranch}`; + if ( + worktreeId in autoModeByWorktree && + typeof autoModeByWorktree[worktreeId]?.maxConcurrency === 'number' + ) { + return autoModeByWorktree[worktreeId].maxConcurrency; + } + } + return globalMax; + } catch { + return DEFAULT_MAX_CONCURRENCY; + } + } + + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Aborted')); + return; + } + const onAbort = () => { + clearTimeout(timeout); + reject(new Error('Aborted')); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort); + }); + } +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts deleted file mode 100644 index ffb87591..00000000 --- a/apps/server/src/services/auto-mode-service.ts +++ /dev/null @@ -1,5913 +0,0 @@ -/** - * Auto Mode Service - Autonomous feature implementation using Claude Agent SDK - * - * Manages: - * - Worktree creation for isolated development - * - Feature execution with Claude - * - Concurrent execution with max concurrency limits - * - Progress streaming via events - * - Verification and merge workflows - */ - -import { ProviderFactory } from '../providers/provider-factory.js'; -import { simpleQuery } from '../providers/simple-query-service.js'; -import type { - ExecuteOptions, - Feature, - ModelProvider, - PipelineStep, - FeatureStatusWithPipeline, - PipelineConfig, - ThinkingLevel, - PlanningMode, - ParsedTask, - PlanSpec, -} from '@automaker/types'; -import { - DEFAULT_PHASE_MODELS, - DEFAULT_MAX_CONCURRENCY, - isClaudeModel, - stripProviderPrefix, -} from '@automaker/types'; -import { - buildPromptWithImages, - classifyError, - loadContextFiles, - appendLearning, - recordMemoryUsage, - createLogger, - atomicWriteJson, - readJsonWithRecovery, - logRecoveryWarning, - DEFAULT_BACKUP_COUNT, -} from '@automaker/utils'; - -const logger = createLogger('AutoMode'); -import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; -import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { - getFeatureDir, - getAutomakerDir, - getFeaturesDir, - getExecutionStatePath, - ensureAutomakerDir, -} from '@automaker/platform'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import path from 'path'; -import * as secureFs from '../lib/secure-fs.js'; -import type { EventEmitter } from '../lib/events.js'; -import { - createAutoModeOptions, - createCustomOptions, - validateWorkingDirectory, -} from '../lib/sdk-options.js'; -import { FeatureLoader } from './feature-loader.js'; -import type { SettingsService } from './settings-service.js'; -import { pipelineService, PipelineService } from './pipeline-service.js'; -import { - getAutoLoadClaudeMdSetting, - filterClaudeMdFromContext, - getMCPServersFromSettings, - getPromptCustomization, - getProviderByModelId, - getPhaseModelWithOverrides, -} from '../lib/settings-helpers.js'; -import { getNotificationService } from './notification-service.js'; - -const execAsync = promisify(exec); - -/** - * Get the current branch name for a git repository - * @param projectPath - Path to the git repository - * @returns The current branch name, or null if not in a git repo or on detached HEAD - */ -async function getCurrentBranch(projectPath: string): Promise { - try { - const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); - const branch = stdout.trim(); - return branch || null; - } catch { - return null; - } -} - -// ParsedTask and PlanSpec types are imported from @automaker/types - -/** - * Information about pipeline status when resuming a feature. - * Used to determine how to handle features stuck in pipeline execution. - * - * @property {boolean} isPipeline - Whether the feature is in a pipeline step - * @property {string | null} stepId - ID of the current pipeline step (e.g., 'step_123') - * @property {number} stepIndex - Index of the step in the sorted pipeline steps (-1 if not found) - * @property {number} totalSteps - Total number of steps in the pipeline - * @property {PipelineStep | null} step - The pipeline step configuration, or null if step not found - * @property {PipelineConfig | null} config - The full pipeline configuration, or null if no pipeline - */ -interface PipelineStatusInfo { - isPipeline: boolean; - stepId: string | null; - stepIndex: number; - totalSteps: number; - step: PipelineStep | null; - config: PipelineConfig | null; -} - -/** - * Parse tasks from generated spec content - * Looks for the ```tasks code block and extracts task lines - * Format: - [ ] T###: Description | File: path/to/file - */ -function parseTasksFromSpec(specContent: string): ParsedTask[] { - const tasks: ParsedTask[] = []; - - // Extract content within ```tasks ... ``` block - const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); - if (!tasksBlockMatch) { - // Try fallback: look for task lines anywhere in content - const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); - if (!taskLines) { - return tasks; - } - // Parse fallback task lines - let currentPhase: string | undefined; - for (const line of taskLines) { - const parsed = parseTaskLine(line, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - return tasks; - } - - const tasksContent = tasksBlockMatch[1]; - const lines = tasksContent.split('\n'); - - let currentPhase: string | undefined; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Check for phase header (e.g., "## Phase 1: Foundation") - const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); - if (phaseMatch) { - currentPhase = phaseMatch[1].trim(); - continue; - } - - // Check for task line - if (trimmedLine.startsWith('- [ ]')) { - const parsed = parseTaskLine(trimmedLine, currentPhase); - if (parsed) { - tasks.push(parsed); - } - } - } - - return tasks; -} - -/** - * Parse a single task line - * Format: - [ ] T###: Description | File: path/to/file - */ -function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { - // Match pattern: - [ ] T###: Description | File: path - const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); - if (!taskMatch) { - // Try simpler pattern without file - const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); - if (simpleMatch) { - return { - id: simpleMatch[1], - description: simpleMatch[2].trim(), - phase: currentPhase, - status: 'pending', - }; - } - return null; - } - - return { - id: taskMatch[1], - description: taskMatch[2].trim(), - filePath: taskMatch[3]?.trim(), - phase: currentPhase, - status: 'pending', - }; -} - -/** - * Detect [TASK_START] marker in text and extract task ID - * Format: [TASK_START] T###: Description - */ -function detectTaskStartMarker(text: string): string | null { - const match = text.match(/\[TASK_START\]\s*(T\d{3})/); - return match ? match[1] : null; -} - -/** - * Detect [TASK_COMPLETE] marker in text and extract task ID - * Format: [TASK_COMPLETE] T###: Brief summary - */ -function detectTaskCompleteMarker(text: string): string | null { - const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); - return match ? match[1] : null; -} - -/** - * Detect [PHASE_COMPLETE] marker in text and extract phase number - * Format: [PHASE_COMPLETE] Phase N complete - */ -function detectPhaseCompleteMarker(text: string): number | null { - const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); - return match ? parseInt(match[1], 10) : null; -} - -/** - * Fallback spec detection when [SPEC_GENERATED] marker is missing - * Looks for structural elements that indicate a spec was generated. - * This is especially important for non-Claude models that may not output - * the explicit [SPEC_GENERATED] marker. - * - * @param text - The text content to check for spec structure - * @returns true if the text appears to be a generated spec - */ -function detectSpecFallback(text: string): boolean { - // Check for key structural elements of a spec - const hasTasksBlock = /```tasks[\s\S]*```/.test(text); - const hasTaskLines = /- \[ \] T\d{3}:/.test(text); - - // Check for common spec sections (case-insensitive) - const hasAcceptanceCriteria = /acceptance criteria/i.test(text); - const hasTechnicalContext = /technical context/i.test(text); - const hasProblemStatement = /problem statement/i.test(text); - const hasUserStory = /user story/i.test(text); - // Additional patterns for different model outputs - const hasGoal = /\*\*Goal\*\*:/i.test(text); - const hasSolution = /\*\*Solution\*\*:/i.test(text); - const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); - const hasOverview = /##\s*(overview|summary)/i.test(text); - - // Spec is detected if we have task structure AND at least some spec content - const hasTaskStructure = hasTasksBlock || hasTaskLines; - const hasSpecContent = - hasAcceptanceCriteria || - hasTechnicalContext || - hasProblemStatement || - hasUserStory || - hasGoal || - hasSolution || - hasImplementation || - hasOverview; - - return hasTaskStructure && hasSpecContent; -} - -/** - * Extract summary from text content - * Checks for multiple formats in order of priority: - * 1. Explicit tags - * 2. ## Summary section (markdown) - * 3. **Goal**: section (lite planning mode) - * 4. **Problem**: or **Problem Statement**: section (spec/full modes) - * 5. **Solution**: section as fallback - * - * Note: Uses last match for each pattern to avoid stale summaries - * when agent output accumulates across multiple runs. - * - * @param text - The text content to extract summary from - * @returns The extracted summary string, or null if no summary found - */ -function extractSummary(text: string): string | null { - // Helper to truncate content to first paragraph with max length - const truncate = (content: string, maxLength: number): string => { - const firstPara = content.split(/\n\n/)[0]; - return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; - }; - - // Helper to get last match from matchAll results - const getLastMatch = (matches: IterableIterator): RegExpMatchArray | null => { - const arr = [...matches]; - return arr.length > 0 ? arr[arr.length - 1] : null; - }; - - // Check for explicit tags first (use last match to avoid stale summaries) - const summaryMatches = text.matchAll(/([\s\S]*?)<\/summary>/g); - const summaryMatch = getLastMatch(summaryMatches); - if (summaryMatch) { - return summaryMatch[1].trim(); - } - - // Check for ## Summary section (use last match) - const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); - const sectionMatch = getLastMatch(sectionMatches); - if (sectionMatch) { - return truncate(sectionMatch[1].trim(), 500); - } - - // Check for **Goal**: section (lite mode, use last match) - const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); - const goalMatch = getLastMatch(goalMatches); - if (goalMatch) { - return goalMatch[1].trim(); - } - - // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) - const problemMatches = text.matchAll( - /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi - ); - const problemMatch = getLastMatch(problemMatches); - if (problemMatch) { - return truncate(problemMatch[1].trim(), 500); - } - - // Check for **Solution**: section as fallback (use last match) - const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); - const solutionMatch = getLastMatch(solutionMatches); - if (solutionMatch) { - return truncate(solutionMatch[1].trim(), 300); - } - - return null; -} - -// Feature type is imported from feature-loader.js -// Extended type with planning fields for local use -interface FeatureWithPlanning extends Feature { - planningMode?: PlanningMode; - planSpec?: PlanSpec; - requirePlanApproval?: boolean; -} - -interface RunningFeature { - featureId: string; - projectPath: string; - worktreePath: string | null; - branchName: string | null; - abortController: AbortController; - isAutoMode: boolean; - startTime: number; - leaseCount: number; - model?: string; - provider?: ModelProvider; -} - -interface AutoLoopState { - projectPath: string; - maxConcurrency: number; - abortController: AbortController; - isRunning: boolean; -} - -interface PendingApproval { - resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void; - reject: (error: Error) => void; - featureId: string; - projectPath: string; -} - -interface AutoModeConfig { - maxConcurrency: number; - useWorktrees: boolean; - projectPath: string; - branchName: string | null; // null = main worktree -} - -/** - * Generate a unique key for worktree-scoped auto loop state - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ -function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { - const normalizedBranch = branchName === 'main' ? null : branchName; - return `${projectPath}::${normalizedBranch ?? '__main__'}`; -} - -/** - * Per-worktree autoloop state for multi-project/worktree support - */ -interface ProjectAutoLoopState { - abortController: AbortController; - config: AutoModeConfig; - isRunning: boolean; - consecutiveFailures: { timestamp: number; error: string }[]; - pausedDueToFailures: boolean; - hasEmittedIdleEvent: boolean; - branchName: string | null; // null = main worktree -} - -/** - * Execution state for recovery after server restart - * Tracks which features were running and auto-loop configuration - */ -interface ExecutionState { - version: 1; - autoLoopWasRunning: boolean; - maxConcurrency: number; - projectPath: string; - branchName: string | null; // null = main worktree - runningFeatureIds: string[]; - savedAt: string; -} - -// Default empty execution state -const DEFAULT_EXECUTION_STATE: ExecutionState = { - version: 1, - autoLoopWasRunning: false, - maxConcurrency: DEFAULT_MAX_CONCURRENCY, - projectPath: '', - branchName: null, - runningFeatureIds: [], - savedAt: '', -}; - -// Constants for consecutive failure tracking -const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures -const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive - -export class AutoModeService { - private events: EventEmitter; - private runningFeatures = new Map(); - private autoLoop: AutoLoopState | null = null; - private featureLoader = new FeatureLoader(); - // Per-project autoloop state (supports multiple concurrent projects) - private autoLoopsByProject = new Map(); - // Legacy single-project properties (kept for backward compatibility during transition) - private autoLoopRunning = false; - private autoLoopAbortController: AbortController | null = null; - private config: AutoModeConfig | null = null; - private pendingApprovals = new Map(); - private settingsService: SettingsService | null = null; - // Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject) - private consecutiveFailures: { timestamp: number; error: string }[] = []; - private pausedDueToFailures = false; - // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) - private hasEmittedIdleEvent = false; - - constructor(events: EventEmitter, settingsService?: SettingsService) { - this.events = events; - this.settingsService = settingsService ?? null; - } - - /** - * Acquire a slot in the runningFeatures map for a feature. - * Implements reference counting via leaseCount to support nested calls - * (e.g., resumeFeature -> executeFeature). - * - * @param params.featureId - ID of the feature to track - * @param params.projectPath - Path to the project - * @param params.isAutoMode - Whether this is an auto-mode execution - * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features - * @param params.abortController - Optional abort controller to use - * @returns The RunningFeature entry (existing or newly created) - * @throws Error if feature is already running and allowReuse is false - */ - private acquireRunningFeature(params: { - featureId: string; - projectPath: string; - isAutoMode: boolean; - allowReuse?: boolean; - abortController?: AbortController; - }): RunningFeature { - const existing = this.runningFeatures.get(params.featureId); - if (existing) { - if (!params.allowReuse) { - throw new Error('already running'); - } - existing.leaseCount += 1; - return existing; - } - - const abortController = params.abortController ?? new AbortController(); - const entry: RunningFeature = { - featureId: params.featureId, - projectPath: params.projectPath, - worktreePath: null, - branchName: null, - abortController, - isAutoMode: params.isAutoMode, - startTime: Date.now(), - leaseCount: 1, - }; - this.runningFeatures.set(params.featureId, entry); - return entry; - } - - /** - * Release a slot in the runningFeatures map for a feature. - * Decrements leaseCount and only removes the entry when it reaches zero, - * unless force option is used. - * - * @param featureId - ID of the feature to release - * @param options.force - If true, immediately removes the entry regardless of leaseCount - */ - private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { - const entry = this.runningFeatures.get(featureId); - if (!entry) { - return; - } - - if (options?.force) { - this.runningFeatures.delete(featureId); - return; - } - - entry.leaseCount -= 1; - if (entry.leaseCount <= 0) { - this.runningFeatures.delete(featureId); - } - } - - /** - * Reset features that were stuck in transient states due to server crash - * Called when auto mode is enabled to clean up from previous session - * @param projectPath - The project path to reset features for - */ - async resetStuckFeatures(projectPath: string): Promise { - const featuresDir = getFeaturesDir(projectPath); - - try { - const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - const feature = result.data; - if (!feature) continue; - - let needsUpdate = false; - - // Reset in_progress features back to ready/backlog - if (feature.status === 'in_progress') { - const hasApprovedPlan = feature.planSpec?.status === 'approved'; - feature.status = hasApprovedPlan ? 'ready' : 'backlog'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}` - ); - } - - // Reset generating planSpec status back to pending (spec generation was interrupted) - if (feature.planSpec?.status === 'generating') { - feature.planSpec.status = 'pending'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending` - ); - } - - // Reset any in_progress tasks back to pending (task execution was interrupted) - if (feature.planSpec?.tasks) { - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'pending'; - needsUpdate = true; - logger.info( - `[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` - ); - // Clear currentTaskId if it points to this reverted task - if (feature.planSpec?.currentTaskId === task.id) { - feature.planSpec.currentTaskId = undefined; - logger.info( - `[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` - ); - } - } - } - } - - if (needsUpdate) { - feature.updatedAt = new Date().toISOString(); - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - } - } - } catch (error) { - // If features directory doesn't exist, that's fine - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error); - } - } - } - - /** - * Track a failure and check if we should pause due to consecutive failures. - * This handles cases where the SDK doesn't return useful error messages. - * @param projectPath - The project to track failure for - * @param errorInfo - Error information - */ - private trackFailureAndCheckPauseForProject( - projectPath: string, - errorInfo: { type: string; message: string } - ): boolean { - const projectState = this.autoLoopsByProject.get(projectPath); - if (!projectState) { - // Fall back to legacy global tracking - return this.trackFailureAndCheckPause(errorInfo); - } - - const now = Date.now(); - - // Add this failure - projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); - - // Remove old failures outside the window - projectState.consecutiveFailures = projectState.consecutiveFailures.filter( - (f) => now - f.timestamp < FAILURE_WINDOW_MS - ); - - // Check if we've hit the threshold - if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { - return true; // Should pause - } - - // Also immediately pause for known quota/rate limit errors - if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { - return true; - } - - return false; - } - - /** - * Track a failure and check if we should pause due to consecutive failures (legacy global). - */ - private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean { - const now = Date.now(); - - // Add this failure - this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); - - // Remove old failures outside the window - this.consecutiveFailures = this.consecutiveFailures.filter( - (f) => now - f.timestamp < FAILURE_WINDOW_MS - ); - - // Check if we've hit the threshold - if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { - return true; // Should pause - } - - // Also immediately pause for known quota/rate limit errors - if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { - return true; - } - - return false; - } - - /** - * Signal that we should pause due to repeated failures or quota exhaustion. - * This will pause the auto loop for a specific project. - * @param projectPath - The project to pause - * @param errorInfo - Error information - */ - private signalShouldPauseForProject( - projectPath: string, - errorInfo: { type: string; message: string } - ): void { - const projectState = this.autoLoopsByProject.get(projectPath); - if (!projectState) { - // Fall back to legacy global pause - this.signalShouldPause(errorInfo); - return; - } - - if (projectState.pausedDueToFailures) { - return; // Already paused - } - - projectState.pausedDueToFailures = true; - const failureCount = projectState.consecutiveFailures.length; - logger.info( - `Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` - ); - - // Emit event to notify UI - this.emitAutoModeEvent('auto_mode_paused_failures', { - message: - failureCount >= CONSECUTIVE_FAILURE_THRESHOLD - ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` - : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', - errorType: errorInfo.type, - originalError: errorInfo.message, - failureCount, - projectPath, - }); - - // Stop the auto loop for this project - this.stopAutoLoopForProject(projectPath); - } - - /** - * Signal that we should pause due to repeated failures or quota exhaustion (legacy global). - */ - private signalShouldPause(errorInfo: { type: string; message: string }): void { - if (this.pausedDueToFailures) { - return; // Already paused - } - - this.pausedDueToFailures = true; - const failureCount = this.consecutiveFailures.length; - logger.info( - `Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` - ); - - // Emit event to notify UI - this.emitAutoModeEvent('auto_mode_paused_failures', { - message: - failureCount >= CONSECUTIVE_FAILURE_THRESHOLD - ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` - : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', - errorType: errorInfo.type, - originalError: errorInfo.message, - failureCount, - projectPath: this.config?.projectPath, - }); - - // Stop the auto loop - this.stopAutoLoop(); - } - - /** - * Reset failure tracking for a specific project - * @param projectPath - The project to reset failure tracking for - */ - private resetFailureTrackingForProject(projectPath: string): void { - const projectState = this.autoLoopsByProject.get(projectPath); - if (projectState) { - projectState.consecutiveFailures = []; - projectState.pausedDueToFailures = false; - } - } - - /** - * Reset failure tracking (called when user manually restarts auto mode) - legacy global - */ - private resetFailureTracking(): void { - this.consecutiveFailures = []; - this.pausedDueToFailures = false; - } - - /** - * Record a successful feature completion to reset consecutive failure count for a project - * @param projectPath - The project to record success for - */ - private recordSuccessForProject(projectPath: string): void { - const projectState = this.autoLoopsByProject.get(projectPath); - if (projectState) { - projectState.consecutiveFailures = []; - } - } - - /** - * Record a successful feature completion to reset consecutive failure count - legacy global - */ - private recordSuccess(): void { - this.consecutiveFailures = []; - } - - private async resolveMaxConcurrency( - projectPath: string, - branchName: string | null, - provided?: number - ): Promise { - if (typeof provided === 'number' && Number.isFinite(provided)) { - return provided; - } - - if (!this.settingsService) { - return DEFAULT_MAX_CONCURRENCY; - } - - try { - const settings = await this.settingsService.getGlobalSettings(); - const globalMax = - typeof settings.maxConcurrency === 'number' - ? settings.maxConcurrency - : DEFAULT_MAX_CONCURRENCY; - const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; - const autoModeByWorktree = settings.autoModeByWorktree; - - if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { - // Normalize branch name to match UI convention: - // - null or "main" -> "__main__" (UI treats "main" as the main worktree) - // This ensures consistency with how the UI stores worktree settings - const normalizedBranch = branchName === 'main' ? null : branchName; - const key = `${projectId}::${normalizedBranch ?? '__main__'}`; - const entry = autoModeByWorktree[key]; - if (entry && typeof entry.maxConcurrency === 'number') { - return entry.maxConcurrency; - } - } - - return globalMax; - } catch { - return DEFAULT_MAX_CONCURRENCY; - } - } - - /** - * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) - * @param projectPath - The project to start auto mode for - * @param branchName - The branch name for worktree scoping, null for main worktree - * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) - */ - async startAutoLoopForProject( - projectPath: string, - branchName: string | null = null, - maxConcurrency?: number - ): Promise { - const resolvedMaxConcurrency = await this.resolveMaxConcurrency( - projectPath, - branchName, - maxConcurrency - ); - - // Use worktree-scoped key - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - - // Check if this project/worktree already has an active autoloop - const existingState = this.autoLoopsByProject.get(worktreeKey); - if (existingState?.isRunning) { - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - throw new Error( - `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` - ); - } - - // Create new project/worktree autoloop state - const abortController = new AbortController(); - const config: AutoModeConfig = { - maxConcurrency: resolvedMaxConcurrency, - useWorktrees: true, - projectPath, - branchName, - }; - - const projectState: ProjectAutoLoopState = { - abortController, - config, - isRunning: true, - consecutiveFailures: [], - pausedDueToFailures: false, - hasEmittedIdleEvent: false, - branchName, - }; - - this.autoLoopsByProject.set(worktreeKey, projectState); - - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info( - `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` - ); - - // Reset any features that were stuck in transient states due to previous server crash - try { - await this.resetStuckFeatures(projectPath); - } catch (error) { - logger.warn(`[startAutoLoopForProject] Error resetting stuck features:`, error); - // Don't fail startup due to reset errors - } - - this.emitAutoModeEvent('auto_mode_started', { - message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, - projectPath, - branchName, - maxConcurrency: resolvedMaxConcurrency, - }); - - // Save execution state for recovery after restart - await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency); - - // Run the loop in the background - this.runAutoLoopForProject(worktreeKey).catch((error) => { - const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error); - const errorInfo = classifyError(error); - this.emitAutoModeEvent('auto_mode_error', { - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - branchName, - }); - }); - - return resolvedMaxConcurrency; - } - - /** - * Run the auto loop for a specific project/worktree - * @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__) - */ - private async runAutoLoopForProject(worktreeKey: string): Promise { - const projectState = this.autoLoopsByProject.get(worktreeKey); - if (!projectState) { - logger.warn(`No project state found for ${worktreeKey}, stopping loop`); - return; - } - - const { projectPath, branchName } = projectState.config; - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - - logger.info( - `[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}` - ); - let iterationCount = 0; - - while (projectState.isRunning && !projectState.abortController.signal.aborted) { - iterationCount++; - try { - // Count running features for THIS project/worktree only - const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName); - - // Check if we have capacity for this project/worktree - if (projectRunningCount >= projectState.config.maxConcurrency) { - logger.debug( - `[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...` - ); - await this.sleep(5000); - continue; - } - - // Load pending features for this project/worktree - const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName); - - logger.info( - `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}` - ); - - if (pendingFeatures.length === 0) { - // Emit idle event only once when backlog is empty AND no features are running - if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) { - this.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath, - branchName, - }); - projectState.hasEmittedIdleEvent = true; - logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`); - } else if (projectRunningCount > 0) { - logger.info( - `[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...` - ); - } else { - logger.warn( - `[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.` - ); - } - await this.sleep(10000); - continue; - } - - // Find a feature not currently running and not yet finished - const nextFeature = pendingFeatures.find( - (f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f) - ); - - if (nextFeature) { - logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); - // Reset idle event flag since we're doing work again - projectState.hasEmittedIdleEvent = false; - // Start feature execution in background - this.executeFeature( - projectPath, - nextFeature.id, - projectState.config.useWorktrees, - true - ).catch((error) => { - logger.error(`Feature ${nextFeature.id} error:`, error); - }); - } else { - logger.debug(`[AutoLoop] All pending features are already running`); - } - - await this.sleep(2000); - } catch (error) { - logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error); - await this.sleep(5000); - } - } - - // Mark as not running when loop exits - projectState.isRunning = false; - logger.info( - `[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations` - ); - } - - /** - * Get count of running features for a specific project - */ - private getRunningCountForProject(projectPath: string): number { - let count = 0; - for (const [, feature] of this.runningFeatures) { - if (feature.projectPath === projectPath) { - count++; - } - } - return count; - } - - /** - * Get count of running features for a specific worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch) - */ - private async getRunningCountForWorktree( - projectPath: string, - branchName: string | null - ): Promise { - // Get the actual primary branch name for the project - const primaryBranch = await getCurrentBranch(projectPath); - - let count = 0; - for (const [, feature] of this.runningFeatures) { - // Filter by project path AND branchName to get accurate worktree-specific count - const featureBranch = feature.branchName ?? null; - if (branchName === null) { - // Main worktree: match features with branchName === null OR branchName matching primary branch - const isPrimaryBranch = - featureBranch === null || (primaryBranch && featureBranch === primaryBranch); - if (feature.projectPath === projectPath && isPrimaryBranch) { - count++; - } - } else { - // Feature worktree: exact match - if (feature.projectPath === projectPath && featureBranch === branchName) { - count++; - } - } - } - return count; - } - - /** - * Stop the auto mode loop for a specific project/worktree - * @param projectPath - The project to stop auto mode for - * @param branchName - The branch name, or null for main worktree - */ - async stopAutoLoopForProject( - projectPath: string, - branchName: string | null = null - ): Promise { - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - const projectState = this.autoLoopsByProject.get(worktreeKey); - if (!projectState) { - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`); - return 0; - } - - const wasRunning = projectState.isRunning; - projectState.isRunning = false; - projectState.abortController.abort(); - - // Clear execution state when auto-loop is explicitly stopped - await this.clearExecutionState(projectPath, branchName); - - // Emit stop event - if (wasRunning) { - this.emitAutoModeEvent('auto_mode_stopped', { - message: 'Auto mode stopped', - projectPath, - branchName, - }); - } - - // Remove from map - this.autoLoopsByProject.delete(worktreeKey); - - return await this.getRunningCountForWorktree(projectPath, branchName); - } - - /** - * Check if auto mode is running for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - const projectState = this.autoLoopsByProject.get(worktreeKey); - return projectState?.isRunning ?? false; - } - - /** - * Get auto loop config for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - getAutoLoopConfigForProject( - projectPath: string, - branchName: string | null = null - ): AutoModeConfig | null { - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - const projectState = this.autoLoopsByProject.get(worktreeKey); - return projectState?.config ?? null; - } - - /** - * Save execution state for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - * @param maxConcurrency - Maximum concurrent features - */ - private async saveExecutionStateForProject( - projectPath: string, - branchName: string | null, - maxConcurrency: number - ): Promise { - try { - await ensureAutomakerDir(projectPath); - const statePath = getExecutionStatePath(projectPath); - const runningFeatureIds = Array.from(this.runningFeatures.entries()) - .filter(([, f]) => f.projectPath === projectPath) - .map(([id]) => id); - - const state: ExecutionState = { - version: 1, - autoLoopWasRunning: true, - maxConcurrency, - projectPath, - branchName, - runningFeatureIds, - savedAt: new Date().toISOString(), - }; - await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info( - `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features` - ); - } catch (error) { - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.error(`Failed to save execution state for ${worktreeDesc} in ${projectPath}:`, error); - } - } - - /** - * Start the auto mode loop - continuously picks and executes pending features - * @deprecated Use startAutoLoopForProject instead for multi-project support - */ - async startAutoLoop( - projectPath: string, - maxConcurrency = DEFAULT_MAX_CONCURRENCY - ): Promise { - // For backward compatibility, delegate to the new per-project method - // But also maintain legacy state for existing code that might check it - if (this.autoLoopRunning) { - throw new Error('Auto mode is already running'); - } - - // Reset failure tracking when user manually starts auto mode - this.resetFailureTracking(); - - this.autoLoopRunning = true; - this.autoLoopAbortController = new AbortController(); - this.config = { - maxConcurrency, - useWorktrees: true, - projectPath, - branchName: null, - }; - - this.emitAutoModeEvent('auto_mode_started', { - message: `Auto mode started with max ${maxConcurrency} concurrent features`, - projectPath, - }); - - // Save execution state for recovery after restart - await this.saveExecutionState(projectPath); - - // Note: Memory folder initialization is now handled by loadContextFiles - - // Run the loop in the background - this.runAutoLoop().catch((error) => { - logger.error('Loop error:', error); - const errorInfo = classifyError(error); - this.emitAutoModeEvent('auto_mode_error', { - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - }); - } - - /** - * @deprecated Use runAutoLoopForProject instead - */ - private async runAutoLoop(): Promise { - while ( - this.autoLoopRunning && - this.autoLoopAbortController && - !this.autoLoopAbortController.signal.aborted - ) { - try { - // Check if we have capacity - if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { - await this.sleep(5000); - continue; - } - - // Load pending features - const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath); - - if (pendingFeatures.length === 0) { - // Emit idle event only once when backlog is empty AND no features are running - const runningCount = this.runningFeatures.size; - if (runningCount === 0 && !this.hasEmittedIdleEvent) { - this.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', - projectPath: this.config!.projectPath, - }); - this.hasEmittedIdleEvent = true; - logger.info(`[AutoLoop] Backlog complete, auto mode now idle`); - } else if (runningCount > 0) { - logger.debug( - `[AutoLoop] No pending features, ${runningCount} still running, waiting...` - ); - } else { - logger.debug(`[AutoLoop] No pending features, waiting for new items...`); - } - await this.sleep(10000); - continue; - } - - // Find a feature not currently running - const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); - - if (nextFeature) { - // Reset idle event flag since we're doing work again - this.hasEmittedIdleEvent = false; - // Start feature execution in background - this.executeFeature( - this.config!.projectPath, - nextFeature.id, - this.config!.useWorktrees, - true - ).catch((error) => { - logger.error(`Feature ${nextFeature.id} error:`, error); - }); - } - - await this.sleep(2000); - } catch (error) { - logger.error('Loop iteration error:', error); - await this.sleep(5000); - } - } - - this.autoLoopRunning = false; - } - - /** - * Stop the auto mode loop - * @deprecated Use stopAutoLoopForProject instead for multi-project support - */ - async stopAutoLoop(): Promise { - const wasRunning = this.autoLoopRunning; - const projectPath = this.config?.projectPath; - this.autoLoopRunning = false; - if (this.autoLoopAbortController) { - this.autoLoopAbortController.abort(); - this.autoLoopAbortController = null; - } - - // Clear execution state when auto-loop is explicitly stopped - if (projectPath) { - await this.clearExecutionState(projectPath); - } - - // Emit stop event immediately when user explicitly stops - if (wasRunning) { - this.emitAutoModeEvent('auto_mode_stopped', { - message: 'Auto mode stopped', - projectPath, - }); - } - - return this.runningFeatures.size; - } - - /** - * Check if there's capacity to start a feature on a worktree. - * This respects per-worktree agent limits from autoModeByWorktree settings. - * - * @param projectPath - The main project path - * @param featureId - The feature ID to check capacity for - * @returns Object with hasCapacity boolean and details about current/max agents - */ - async checkWorktreeCapacity( - projectPath: string, - featureId: string - ): Promise<{ - hasCapacity: boolean; - currentAgents: number; - maxAgents: number; - branchName: string | null; - }> { - // Load feature to get branchName - const feature = await this.loadFeature(projectPath, featureId); - const rawBranchName = feature?.branchName ?? null; - // Normalize "main" to null to match UI convention for main worktree - const branchName = rawBranchName === 'main' ? null : rawBranchName; - - // Get per-worktree limit - const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName); - - // Get current running count for this worktree - const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName); - - return { - hasCapacity: currentAgents < maxAgents, - currentAgents, - maxAgents, - branchName, - }; - } - - /** - * Execute a single feature - * @param projectPath - The main project path - * @param featureId - The feature ID to execute - * @param useWorktrees - Whether to use worktrees for isolation - * @param isAutoMode - Whether this is running in auto mode - */ - async executeFeature( - projectPath: string, - featureId: string, - useWorktrees = false, - isAutoMode = false, - providedWorktreePath?: string, - options?: { - continuationPrompt?: string; - /** Internal flag: set to true when called from a method that already tracks the feature */ - _calledInternally?: boolean; - } - ): Promise { - const tempRunningFeature = this.acquireRunningFeature({ - featureId, - projectPath, - isAutoMode, - allowReuse: options?._calledInternally, - }); - const abortController = tempRunningFeature.abortController; - - // Save execution state when feature starts - if (isAutoMode) { - await this.saveExecutionState(projectPath); - } - // Declare feature outside try block so it's available in catch for error reporting - let feature: Awaited> | null = null; - - try { - // Validate that project path is allowed using centralized validation - validateWorkingDirectory(projectPath); - - // Load feature details FIRST to get status and plan info - feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - // Check if feature has existing context - if so, resume instead of starting fresh - // Skip this check if we're already being called with a continuation prompt (from resumeFeature) - if (!options?.continuationPrompt) { - // If feature has an approved plan but we don't have a continuation prompt yet, - // we should build one to ensure it proceeds with multi-agent execution - if (feature.planSpec?.status === 'approved') { - logger.info(`Feature ${featureId} has approved plan, building continuation prompt`); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - const planContent = feature.planSpec.content || ''; - - // Build continuation prompt using centralized template - let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; - continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, ''); - continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); - - // Recursively call executeFeature with the continuation prompt - // Feature is already tracked, the recursive call will reuse the entry - return await this.executeFeature( - projectPath, - featureId, - useWorktrees, - isAutoMode, - providedWorktreePath, - { - continuationPrompt, - _calledInternally: true, - } - ); - } - - const hasExistingContext = await this.contextExists(projectPath, featureId); - if (hasExistingContext) { - logger.info( - `Feature ${featureId} has existing context, resuming instead of starting fresh` - ); - // Feature is already tracked, resumeFeature will reuse the entry - return await this.resumeFeature(projectPath, featureId, useWorktrees, true); - } - } - - // Derive workDir from feature.branchName - // Worktrees should already be created when the feature is added/edited - let worktreePath: string | null = null; - const branchName = feature.branchName; - - if (useWorktrees && branchName) { - // Try to find existing worktree for this branch - // Worktree should already exist (created when feature was added/edited) - worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); - - if (worktreePath) { - logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); - } else { - // Worktree doesn't exist - log warning and continue with project path - logger.warn(`Worktree for branch "${branchName}" not found, using project path`); - } - } - - // Ensure workDir is always an absolute path for cross-platform compatibility - const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); - - // Validate that working directory is allowed using centralized validation - validateWorkingDirectory(workDir); - - // Update running feature with actual worktree info - tempRunningFeature.worktreePath = worktreePath; - tempRunningFeature.branchName = branchName ?? null; - - // Update feature status to in_progress BEFORE emitting event - // This ensures the frontend sees the updated status when it reloads features - await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - - // Emit feature start event AFTER status update so frontend sees correct status - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - branchName: feature.branchName ?? null, - feature: { - id: featureId, - title: feature.title || 'Loading...', - description: feature.description || 'Feature is starting', - }, - }); - - // Load autoLoadClaudeMd setting to determine context loading strategy - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build the prompt - use continuation prompt if provided (for recovery after plan approval) - let prompt: string; - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files - // Context loader uses task context to select relevant memory files - const contextResult = await loadContextFiles({ - projectPath, - fsModule: secureFs as Parameters[0]['fsModule'], - taskContext: { - title: feature.title ?? '', - description: feature.description ?? '', - }, - }); - - // When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication - // (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md - // Note: contextResult.formattedPrompt now includes both context AND memory - const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); - - if (options?.continuationPrompt) { - // Continuation prompt is used when recovering from a plan approval - // The plan was already approved, so skip the planning phase - prompt = options.continuationPrompt; - logger.info(`Using continuation prompt for feature ${featureId}`); - } else { - // Normal flow: build prompt with planning phase - const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); - const planningPrefix = await this.getPlanningPromptPrefix(feature); - prompt = planningPrefix + featurePrompt; - - // Emit planning mode info - if (feature.planningMode && feature.planningMode !== 'skip') { - this.emitAutoModeEvent('planning_started', { - featureId: feature.id, - mode: feature.planningMode, - message: `Starting ${feature.planningMode} planning phase`, - }); - } - } - - // Extract image paths from feature - const imagePaths = feature.imagePaths?.map((img) => - typeof img === 'string' ? img : img.path - ); - - // Get model from feature and determine provider - const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); - const provider = ProviderFactory.getProviderNameForModel(model); - logger.info( - `Executing feature ${featureId} with model: ${model}, provider: ${provider} in ${workDir}` - ); - - // Store model and provider in running feature for tracking - tempRunningFeature.model = model; - tempRunningFeature.provider = provider; - - // Run the agent with the feature's model and images - // Context files are passed as system prompt for higher priority - await this.runAgent( - workDir, - featureId, - prompt, - abortController, - projectPath, - imagePaths, - model, - { - projectPath, - planningMode: feature.planningMode, - requirePlanApproval: feature.requirePlanApproval, - systemPrompt: combinedSystemPrompt || undefined, - autoLoadClaudeMd, - thinkingLevel: feature.thinkingLevel, - branchName: feature.branchName ?? null, - } - ); - - // Check for pipeline steps and execute them - const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); - // Filter out excluded pipeline steps and sort by order - const excludedStepIds = new Set(feature.excludedPipelineSteps || []); - const sortedSteps = [...(pipelineConfig?.steps || [])] - .sort((a, b) => a.order - b.order) - .filter((step) => !excludedStepIds.has(step.id)); - - if (sortedSteps.length > 0) { - // Execute pipeline steps sequentially - await this.executePipelineSteps( - projectPath, - featureId, - feature, - sortedSteps, - workDir, - abortController, - autoLoadClaudeMd - ); - } - - // Determine final status based on testing mode: - // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) - // - skipTests=true (manual verification): go to 'waiting_approval' for manual review - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - - // Record success to reset consecutive failure tracking - this.recordSuccess(); - - // Record learnings, memory usage, and extract summary after successful feature completion - try { - const featureDir = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDir, 'agent-output.md'); - let agentOutput = ''; - try { - const outputContent = await secureFs.readFile(outputPath, 'utf-8'); - agentOutput = - typeof outputContent === 'string' ? outputContent : outputContent.toString(); - } catch { - // Agent output might not exist yet - } - - // Extract and save summary from agent output - if (agentOutput) { - const summary = extractSummary(agentOutput); - if (summary) { - logger.info(`Extracted summary for feature ${featureId}`); - await this.saveFeatureSummary(projectPath, featureId, summary); - } - } - - // Record memory usage if we loaded any memory files - if (contextResult.memoryFiles.length > 0 && agentOutput) { - await recordMemoryUsage( - projectPath, - contextResult.memoryFiles, - agentOutput, - true, // success - secureFs as Parameters[4] - ); - } - - // Extract and record learnings from the agent output - await this.recordLearningsFromFeature(projectPath, feature, agentOutput); - } catch (learningError) { - console.warn('[AutoMode] Failed to record learnings:', learningError); - } - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: true, - message: `Feature completed in ${Math.round( - (Date.now() - tempRunningFeature.startTime) / 1000 - )}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`, - projectPath, - model: tempRunningFeature.model, - provider: tempRunningFeature.provider, - }); - } catch (error) { - const errorInfo = classifyError(error); - - if (errorInfo.isAbort) { - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - passes: false, - message: 'Feature stopped by user', - projectPath, - }); - } else { - logger.error(`Feature ${featureId} failed:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); - this.emitAutoModeEvent('auto_mode_error', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - - // Track this failure and check if we should pause auto mode - // This handles both specific quota/rate limit errors AND generic failures - // that may indicate quota exhaustion (SDK doesn't always return useful errors) - const shouldPause = this.trackFailureAndCheckPause({ - type: errorInfo.type, - message: errorInfo.message, - }); - - if (shouldPause) { - this.signalShouldPause({ - type: errorInfo.type, - message: errorInfo.message, - }); - } - } - } finally { - logger.info(`Feature ${featureId} execution ended, cleaning up runningFeatures`); - logger.info( - `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - this.releaseRunningFeature(featureId); - - // Update execution state after feature completes - if (this.autoLoopRunning && projectPath) { - await this.saveExecutionState(projectPath); - } - } - } - - /** - * Execute pipeline steps sequentially after initial feature implementation - */ - private async executePipelineSteps( - projectPath: string, - featureId: string, - feature: Feature, - steps: PipelineStep[], - workDir: string, - abortController: AbortController, - autoLoadClaudeMd: boolean - ): Promise { - logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Load context files once with feature context for smart memory selection - const contextResult = await loadContextFiles({ - projectPath, - fsModule: secureFs as Parameters[0]['fsModule'], - taskContext: { - title: feature.title ?? '', - description: feature.description ?? '', - }, - }); - const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); - - // Load previous agent output for context continuity - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - let previousContext = ''; - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No previous context - } - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const pipelineStatus = `pipeline_${step.id}`; - - // Update feature status to current pipeline step - await this.updateFeatureStatus(projectPath, featureId, pipelineStatus); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName: feature.branchName ?? null, - content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, - projectPath, - }); - - this.emitAutoModeEvent('pipeline_step_started', { - featureId, - stepId: step.id, - stepName: step.name, - stepIndex: i, - totalSteps: steps.length, - projectPath, - }); - - // Build prompt for this pipeline step - const prompt = this.buildPipelineStepPrompt( - step, - feature, - previousContext, - prompts.taskExecution - ); - - // Get model from feature - const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); - - // Run the agent for this pipeline step - await this.runAgent( - workDir, - featureId, - prompt, - abortController, - projectPath, - undefined, // no images for pipeline steps - model, - { - projectPath, - planningMode: 'skip', // Pipeline steps don't need planning - requirePlanApproval: false, - previousContent: previousContext, - systemPrompt: contextFilesPrompt || undefined, - autoLoadClaudeMd, - thinkingLevel: feature.thinkingLevel, - } - ); - - // Load updated context for next step - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No context update - } - - this.emitAutoModeEvent('pipeline_step_complete', { - featureId, - stepId: step.id, - stepName: step.name, - stepIndex: i, - totalSteps: steps.length, - projectPath, - }); - - logger.info( - `Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}` - ); - } - - logger.info(`All pipeline steps completed for feature ${featureId}`); - } - - /** - * Build the prompt for a pipeline step - */ - private buildPipelineStepPrompt( - step: PipelineStep, - feature: Feature, - previousContext: string, - taskExecutionPrompts: { - implementationInstructions: string; - playwrightVerificationInstructions: string; - } - ): string { - let prompt = `## Pipeline Step: ${step.name} - -This is an automated pipeline step following the initial feature implementation. - -### Feature Context -${this.buildFeaturePrompt(feature, taskExecutionPrompts)} - -`; - - if (previousContext) { - prompt += `### Previous Work -The following is the output from the previous work on this feature: - -${previousContext} - -`; - } - - prompt += `### Pipeline Step Instructions -${step.instructions} - -### Task -Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`; - - return prompt; - } - - /** - * Stop a specific feature - */ - async stopFeature(featureId: string): Promise { - const running = this.runningFeatures.get(featureId); - if (!running) { - return false; - } - - // Cancel any pending plan approval for this feature - this.cancelPlanApproval(featureId); - - running.abortController.abort(); - - // Remove from running features immediately to allow resume - // The abort signal will still propagate to stop any ongoing execution - this.releaseRunningFeature(featureId, { force: true }); - - return true; - } - - /** - * Resume a feature (continues from saved context or starts fresh if no context) - * - * This method handles interrupted features regardless of whether they have saved context: - * - With context: Continues from where the agent left off using the saved agent-output.md - * - Without context: Starts fresh execution (feature was interrupted before any agent output) - * - Pipeline features: Delegates to resumePipelineFeature for specialized handling - * - * @param projectPath - Path to the project - * @param featureId - ID of the feature to resume - * @param useWorktrees - Whether to use git worktrees for isolation - * @param _calledInternally - Internal flag to prevent double-tracking when called from other methods - */ - async resumeFeature( - projectPath: string, - featureId: string, - useWorktrees = false, - /** Internal flag: set to true when called from a method that already tracks the feature */ - _calledInternally = false - ): Promise { - // Idempotent check: if feature is already being resumed/running, skip silently - // This prevents race conditions when multiple callers try to resume the same feature - if (!_calledInternally && this.isFeatureRunning(featureId)) { - logger.info( - `[AutoMode] Feature ${featureId} is already being resumed/running, skipping duplicate resume request` - ); - return; - } - - this.acquireRunningFeature({ - featureId, - projectPath, - isAutoMode: false, - allowReuse: _calledInternally, - }); - - try { - // Load feature to check status - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - logger.info( - `[AutoMode] Resuming feature ${featureId} (${feature.title}) - current status: ${feature.status}` - ); - - // Check if feature is stuck in a pipeline step - const pipelineInfo = await this.detectPipelineStatus( - projectPath, - featureId, - (feature.status || '') as FeatureStatusWithPipeline - ); - - if (pipelineInfo.isPipeline) { - // Feature stuck in pipeline - use pipeline resume - // Pass _alreadyTracked to prevent double-tracking - logger.info( - `[AutoMode] Feature ${featureId} is in pipeline step ${pipelineInfo.stepId}, using pipeline resume` - ); - return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); - } - - // Normal resume flow for non-pipeline features - // Check if context exists in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - let hasContext = false; - try { - await secureFs.access(contextPath); - hasContext = true; - } catch { - // No context - feature was interrupted before any agent output was saved - } - - if (hasContext) { - // Load previous context and continue - // executeFeatureWithContext -> executeFeature will see feature is already tracked - const context = (await secureFs.readFile(contextPath, 'utf-8')) as string; - logger.info( - `[AutoMode] Resuming feature ${featureId} with saved context (${context.length} chars)` - ); - - // Emit event for UI notification - this.emitAutoModeEvent('auto_mode_feature_resuming', { - featureId, - featureName: feature.title, - projectPath, - hasContext: true, - message: `Resuming feature "${feature.title}" from saved context`, - }); - - return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); - } - - // No context - feature was interrupted before any agent output was saved - // Start fresh execution instead of leaving the feature stuck - logger.info( - `[AutoMode] Feature ${featureId} has no saved context - starting fresh execution` - ); - - // Emit event for UI notification - this.emitAutoModeEvent('auto_mode_feature_resuming', { - featureId, - featureName: feature.title, - projectPath, - hasContext: false, - message: `Starting fresh execution for interrupted feature "${feature.title}" (no previous context found)`, - }); - - return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { - _calledInternally: true, - }); - } finally { - this.releaseRunningFeature(featureId); - } - } - - /** - * Resume a feature that crashed during pipeline execution. - * Handles multiple edge cases to ensure robust recovery: - * - No context file: Restart entire pipeline from beginning - * - Step deleted from config: Complete feature without remaining pipeline steps - * - Valid step exists: Resume from the crashed step and continue - * - * @param {string} projectPath - Absolute path to the project directory - * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) - * @param {boolean} useWorktrees - Whether to use git worktrees for isolation - * @param {PipelineStatusInfo} pipelineInfo - Information about the pipeline status from detectPipelineStatus() - * @returns {Promise} Resolves when resume operation completes or throws on error - * @throws {Error} If pipeline config is null but stepIndex is valid (should never happen) - * @private - */ - private async resumePipelineFeature( - projectPath: string, - feature: Feature, - useWorktrees: boolean, - pipelineInfo: PipelineStatusInfo - ): Promise { - const featureId = feature.id; - console.log( - `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` - ); - - // Check for context file - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - let hasContext = false; - try { - await secureFs.access(contextPath); - hasContext = true; - } catch { - // No context - } - - // Edge Case 1: No context file - restart entire pipeline from beginning - if (!hasContext) { - console.warn( - `[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning` - ); - - // Reset status to in_progress and start fresh - await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - - return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { - _calledInternally: true, - }); - } - - // Edge Case 2: Step no longer exists in pipeline config - if (pipelineInfo.stepIndex === -1) { - console.warn( - `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` - ); - - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: true, - message: - 'Pipeline step no longer exists - feature completed without remaining pipeline steps', - projectPath, - }); - - return; - } - - // Normal case: Valid pipeline step exists, has context - // Resume from the stuck step (re-execute the step that crashed) - if (!pipelineInfo.config) { - throw new Error('Pipeline config is null but stepIndex is valid - this should not happen'); - } - - return this.resumeFromPipelineStep( - projectPath, - feature, - useWorktrees, - pipelineInfo.stepIndex, - pipelineInfo.config - ); - } - - /** - * Resume pipeline execution from a specific step index. - * Re-executes the step that crashed (to handle partial completion), - * then continues executing all remaining pipeline steps in order. - * - * This method handles the complete pipeline resume workflow: - * - Validates feature and step index - * - Locates or creates git worktree if needed - * - Executes remaining steps starting from the crashed step - * - Updates feature status to verified/waiting_approval when complete - * - Emits progress events throughout execution - * - * @param {string} projectPath - Absolute path to the project directory - * @param {Feature} feature - The feature object (already loaded to avoid redundant reads) - * @param {boolean} useWorktrees - Whether to use git worktrees for isolation - * @param {number} startFromStepIndex - Zero-based index of the step to resume from - * @param {PipelineConfig} pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading - * @returns {Promise} Resolves when pipeline execution completes successfully - * @throws {Error} If feature not found, step index invalid, or pipeline execution fails - * @private - */ - private async resumeFromPipelineStep( - projectPath: string, - feature: Feature, - useWorktrees: boolean, - startFromStepIndex: number, - pipelineConfig: PipelineConfig - ): Promise { - const featureId = feature.id; - - // Sort all steps first - const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); - - // Get the current step we're resuming from (using the index from unfiltered list) - if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) { - throw new Error(`Invalid step index: ${startFromStepIndex}`); - } - const currentStep = allSortedSteps[startFromStepIndex]; - - // Filter out excluded pipeline steps - const excludedStepIds = new Set(feature.excludedPipelineSteps || []); - - // Check if the current step is excluded - // If so, use getNextStatus to find the appropriate next step - if (excludedStepIds.has(currentStep.id)) { - logger.info( - `Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step` - ); - const nextStatus = pipelineService.getNextStatus( - `pipeline_${currentStep.id}`, - pipelineConfig, - feature.skipTests ?? false, - feature.excludedPipelineSteps - ); - - // If next status is not a pipeline step, feature is done - if (!pipelineService.isPipelineStatus(nextStatus)) { - await this.updateFeatureStatus(projectPath, featureId, nextStatus); - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: true, - message: 'Pipeline completed (remaining steps excluded)', - projectPath, - }); - return; - } - - // Find the next step and update the start index - const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); - const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId); - if (nextStepIndex === -1) { - throw new Error(`Next step ${nextStepId} not found in pipeline config`); - } - startFromStepIndex = nextStepIndex; - } - - // Get steps to execute (from startFromStepIndex onwards, excluding excluded steps) - const stepsToExecute = allSortedSteps - .slice(startFromStepIndex) - .filter((step) => !excludedStepIds.has(step.id)); - - // If no steps left to execute, complete the feature - if (stepsToExecute.length === 0) { - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: true, - message: 'Pipeline completed (all remaining steps excluded)', - projectPath, - }); - return; - } - - // Use the filtered steps for counting - const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id)); - - logger.info( - `Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` - ); - - const runningEntry = this.acquireRunningFeature({ - featureId, - projectPath, - isAutoMode: false, - allowReuse: true, - }); - const abortController = runningEntry.abortController; - runningEntry.branchName = feature.branchName ?? null; - - try { - // Validate project path - validateWorkingDirectory(projectPath); - - // Derive workDir from feature.branchName - let worktreePath: string | null = null; - const branchName = feature.branchName; - - if (useWorktrees && branchName) { - worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); - if (worktreePath) { - logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); - } else { - logger.warn(`Worktree for branch "${branchName}" not found, using project path`); - } - } - - const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); - validateWorkingDirectory(workDir); - - // Update running feature with worktree info - runningEntry.worktreePath = worktreePath; - runningEntry.branchName = branchName ?? null; - - // Emit resume event - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - branchName: branchName ?? null, - feature: { - id: featureId, - title: feature.title || 'Resuming Pipeline', - description: feature.description, - }, - }); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - projectPath, - branchName: branchName ?? null, - content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, - }); - - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Execute remaining pipeline steps (starting from crashed step) - await this.executePipelineSteps( - projectPath, - featureId, - feature, - stepsToExecute, - workDir, - abortController, - autoLoadClaudeMd - ); - - // Determine final status - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - - logger.info(`Pipeline resume completed successfully for feature ${featureId}`); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: true, - message: 'Pipeline resumed and completed successfully', - projectPath, - }); - } catch (error) { - const errorInfo = classifyError(error); - - if (errorInfo.isAbort) { - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - passes: false, - message: 'Pipeline resume stopped by user', - projectPath, - }); - } else { - logger.error(`Pipeline resume failed for feature ${featureId}:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); - this.emitAutoModeEvent('auto_mode_error', { - featureId, - featureName: feature.title, - branchName: feature.branchName ?? null, - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - } - } finally { - this.releaseRunningFeature(featureId); - } - } - - /** - * Follow up on a feature with additional instructions - */ - async followUpFeature( - projectPath: string, - featureId: string, - prompt: string, - imagePaths?: string[], - useWorktrees = true - ): Promise { - // Validate project path early for fast failure - validateWorkingDirectory(projectPath); - - const runningEntry = this.acquireRunningFeature({ - featureId, - projectPath, - isAutoMode: false, - }); - const abortController = runningEntry.abortController; - - // Load feature info for context FIRST to get branchName - const feature = await this.loadFeature(projectPath, featureId); - - // Derive workDir from feature.branchName - // If no branchName, derive from feature ID: feature/{featureId} - let workDir = path.resolve(projectPath); - let worktreePath: string | null = null; - const branchName = feature?.branchName || `feature/${featureId}`; - - if (useWorktrees && branchName) { - // Try to find existing worktree for this branch - worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); - - if (worktreePath) { - workDir = worktreePath; - logger.info(`Follow-up using worktree for branch "${branchName}": ${workDir}`); - } - } - - // Load previous agent output if it exists - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - let previousContext = ''; - try { - previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; - } catch { - // No previous context - } - - // Load autoLoadClaudeMd setting to determine context loading strategy - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt - const contextResult = await loadContextFiles({ - projectPath, - fsModule: secureFs as Parameters[0]['fsModule'], - taskContext: { - title: feature?.title ?? prompt.substring(0, 200), - description: feature?.description ?? prompt, - }, - }); - - // When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication - // (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md - const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); - - // Build complete prompt with feature info, previous context, and follow-up instructions - let fullPrompt = `## Follow-up on Feature Implementation - -${feature ? this.buildFeaturePrompt(feature, prompts.taskExecution) : `**Feature ID:** ${featureId}`} -`; - - if (previousContext) { - fullPrompt += ` -## Previous Agent Work -The following is the output from the previous implementation attempt: - -${previousContext} -`; - } - - fullPrompt += ` -## Follow-up Instructions -${prompt} - -## Task -Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; - - // Get model from feature and determine provider early for tracking - const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude); - const provider = ProviderFactory.getProviderNameForModel(model); - logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`); - - runningEntry.worktreePath = worktreePath; - runningEntry.branchName = branchName; - runningEntry.model = model; - runningEntry.provider = provider; - - try { - // Update feature status to in_progress BEFORE emitting event - // This ensures the frontend sees the updated status when it reloads features - await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - - // Emit feature start event AFTER status update so frontend sees correct status - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId, - projectPath, - branchName, - feature: feature || { - id: featureId, - title: 'Follow-up', - description: prompt.substring(0, 100), - }, - model, - provider, - }); - - // Copy follow-up images to feature folder - const copiedImagePaths: string[] = []; - if (imagePaths && imagePaths.length > 0) { - const featureDirForImages = getFeatureDir(projectPath, featureId); - const featureImagesDir = path.join(featureDirForImages, 'images'); - - await secureFs.mkdir(featureImagesDir, { recursive: true }); - - for (const imagePath of imagePaths) { - try { - // Get the filename from the path - const filename = path.basename(imagePath); - const destPath = path.join(featureImagesDir, filename); - - // Copy the image - await secureFs.copyFile(imagePath, destPath); - - // Store the absolute path (external storage uses absolute paths) - copiedImagePaths.push(destPath); - } catch (error) { - logger.error(`Failed to copy follow-up image ${imagePath}:`, error); - } - } - } - - // Update feature object with new follow-up images BEFORE building prompt - if (copiedImagePaths.length > 0 && feature) { - const currentImagePaths = feature.imagePaths || []; - const newImagePaths = copiedImagePaths.map((p) => ({ - path: p, - filename: path.basename(p), - mimeType: 'image/png', // Default, could be improved - })); - - feature.imagePaths = [...currentImagePaths, ...newImagePaths]; - } - - // Combine original feature images with new follow-up images - const allImagePaths: string[] = []; - - // Add all images from feature (now includes both original and new) - if (feature?.imagePaths) { - const allPaths = feature.imagePaths.map((img) => - typeof img === 'string' ? img : img.path - ); - allImagePaths.push(...allPaths); - } - - // Save updated feature.json with new images (atomic write with backup) - if (copiedImagePaths.length > 0 && feature) { - const featureDirForSave = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDirForSave, 'feature.json'); - - try { - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - } catch (error) { - logger.error(`Failed to save feature.json:`, error); - } - } - - // Use fullPrompt (already built above) with model and all images - // Note: Follow-ups skip planning mode - they continue from previous work - // Pass previousContext so the history is preserved in the output file - // Context files are passed as system prompt for higher priority - await this.runAgent( - workDir, - featureId, - fullPrompt, - abortController, - projectPath, - allImagePaths.length > 0 ? allImagePaths : imagePaths, - model, - { - projectPath, - planningMode: 'skip', // Follow-ups don't require approval - previousContent: previousContext || undefined, - systemPrompt: contextFilesPrompt || undefined, - autoLoadClaudeMd, - thinkingLevel: feature?.thinkingLevel, - } - ); - - // Determine final status based on testing mode: - // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) - // - skipTests=true (manual verification): go to 'waiting_approval' for manual review - const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); - - // Record success to reset consecutive failure tracking - this.recordSuccess(); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: branchName ?? null, - passes: true, - message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, - projectPath, - model, - provider, - }); - } catch (error) { - const errorInfo = classifyError(error); - if (!errorInfo.isCancellation) { - this.emitAutoModeEvent('auto_mode_error', { - featureId, - featureName: feature?.title, - branchName: branchName ?? null, - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - - // Track this failure and check if we should pause auto mode - const shouldPause = this.trackFailureAndCheckPause({ - type: errorInfo.type, - message: errorInfo.message, - }); - - if (shouldPause) { - this.signalShouldPause({ - type: errorInfo.type, - message: errorInfo.message, - }); - } - } - } finally { - this.releaseRunningFeature(featureId); - } - } - - /** - * Verify a feature's implementation - */ - async verifyFeature(projectPath: string, featureId: string): Promise { - // Load feature to get the name for event reporting - const feature = await this.loadFeature(projectPath, featureId); - - // Worktrees are in project dir - // Sanitize featureId the same way it's sanitized when creating worktrees - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); - let workDir = projectPath; - - try { - await secureFs.access(worktreePath); - workDir = worktreePath; - } catch { - // No worktree - } - - // Run verification - check if tests pass, build works, etc. - const verificationChecks = [ - { cmd: 'npm run lint', name: 'Lint' }, - { cmd: 'npm run typecheck', name: 'Type check' }, - { cmd: 'npm test', name: 'Tests' }, - { cmd: 'npm run build', name: 'Build' }, - ]; - - let allPassed = true; - const results: Array<{ check: string; passed: boolean; output?: string }> = []; - - for (const check of verificationChecks) { - try { - const { stdout, stderr } = await execAsync(check.cmd, { - cwd: workDir, - timeout: 120000, - }); - results.push({ - check: check.name, - passed: true, - output: stdout || stderr, - }); - } catch (error) { - allPassed = false; - results.push({ - check: check.name, - passed: false, - output: (error as Error).message, - }); - break; // Stop on first failure - } - } - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - passes: allPassed, - message: allPassed - ? 'All verification checks passed' - : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, - projectPath, - }); - - return allPassed; - } - - /** - * Commit feature changes - * @param projectPath - The main project path - * @param featureId - The feature ID to commit - * @param providedWorktreePath - Optional: the worktree path where the feature's changes are located - */ - async commitFeature( - projectPath: string, - featureId: string, - providedWorktreePath?: string - ): Promise { - let workDir = projectPath; - - // Use the provided worktree path if given - if (providedWorktreePath) { - try { - await secureFs.access(providedWorktreePath); - workDir = providedWorktreePath; - logger.info(`Committing in provided worktree: ${workDir}`); - } catch { - logger.info( - `Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` - ); - } - } else { - // Fallback: try to find worktree at legacy location - // Sanitize featureId the same way it's sanitized when creating worktrees - const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); - const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); - try { - await secureFs.access(legacyWorktreePath); - workDir = legacyWorktreePath; - logger.info(`Committing in legacy worktree: ${workDir}`); - } catch { - logger.info(`No worktree found, committing in project path: ${workDir}`); - } - } - - try { - // Check for changes - const { stdout: status } = await execAsync('git status --porcelain', { - cwd: workDir, - }); - if (!status.trim()) { - return null; // No changes - } - - // Load feature for commit message - const feature = await this.loadFeature(projectPath, featureId); - const commitMessage = feature - ? await this.generateCommitMessage(feature, workDir) - : `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`; - - // Determine which files to stage - // For feature branches, only stage files changed on this branch to avoid committing unrelated changes - let filesToStage: string[] = []; - - try { - // Get the current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: workDir, - }); - const branch = currentBranch.trim(); - - // Get the base branch (usually main/master) - const { stdout: baseBranchOutput } = await execAsync( - 'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"', - { cwd: workDir } - ); - const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', ''); - - // If we're on a feature branch (not the base branch), only stage files changed on this branch - if (branch !== baseBranch && feature?.branchName) { - try { - // Get files changed on this branch compared to base - const { stdout: branchFiles } = await execAsync( - `git diff --name-only ${baseBranch}...HEAD`, - { cwd: workDir } - ); - - if (branchFiles.trim()) { - filesToStage = branchFiles.trim().split('\n').filter(Boolean); - logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`); - } - } catch (diffError) { - // If diff fails (e.g., base branch doesn't exist), fall back to staging all changes - logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`); - filesToStage = []; - } - } - } catch (error) { - logger.warn(`Could not determine branch-specific files: ${error}`); - } - - // Stage files - if (filesToStage.length > 0) { - // Stage only the specific files changed on this branch - for (const file of filesToStage) { - try { - await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir }); - } catch (error) { - logger.warn(`Failed to stage file ${file}: ${error}`); - } - } - } else { - // Fallback: stage all changes (original behavior) - // This happens for main branch features or when branch detection fails - await execAsync('git add -A', { cwd: workDir }); - } - - // Commit - await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { - cwd: workDir, - }); - - // Get commit hash - const { stdout: hash } = await execAsync('git rev-parse HEAD', { - cwd: workDir, - }); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId, - featureName: feature?.title, - branchName: feature?.branchName ?? null, - passes: true, - message: `Changes committed: ${hash.trim().substring(0, 8)}`, - projectPath, - }); - - return hash.trim(); - } catch (error) { - logger.error(`Commit failed for ${featureId}:`, error); - return null; - } - } - - /** - * Check if context exists for a feature - */ - async contextExists(projectPath: string, featureId: string): Promise { - // Context is stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const contextPath = path.join(featureDir, 'agent-output.md'); - - try { - await secureFs.access(contextPath); - return true; - } catch { - return false; - } - } - - /** - * Analyze project to gather context - */ - async analyzeProject(projectPath: string): Promise { - const abortController = new AbortController(); - - const analysisFeatureId = `analysis-${Date.now()}`; - this.emitAutoModeEvent('auto_mode_feature_start', { - featureId: analysisFeatureId, - projectPath, - branchName: null, // Project analysis is not worktree-specific - feature: { - id: analysisFeatureId, - title: 'Project Analysis', - description: 'Analyzing project structure', - }, - }); - - const prompt = `Analyze this project and provide a summary of: -1. Project structure and architecture -2. Main technologies and frameworks used -3. Key components and their responsibilities -4. Build and test commands -5. Any existing conventions or patterns - -Format your response as a structured markdown document.`; - - try { - // Get model from phase settings with provider info - const { - phaseModel: phaseModelEntry, - provider: analysisClaudeProvider, - credentials, - } = await getPhaseModelWithOverrides( - 'projectAnalysisModel', - this.settingsService, - projectPath, - '[AutoMode]' - ); - const { model: analysisModel, thinkingLevel: analysisThinkingLevel } = - resolvePhaseModel(phaseModelEntry); - logger.info( - 'Using model for project analysis:', - analysisModel, - analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API' - ); - - const provider = ProviderFactory.getProviderForModel(analysisModel); - - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - this.settingsService, - '[AutoMode]' - ); - - // Use createCustomOptions for centralized SDK configuration with CLAUDE.md support - const sdkOptions = createCustomOptions({ - cwd: projectPath, - model: analysisModel, - maxTurns: 5, - allowedTools: ['Read', 'Glob', 'Grep'], - abortController, - autoLoadClaudeMd, - thinkingLevel: analysisThinkingLevel, - }); - - const options: ExecuteOptions = { - prompt, - model: sdkOptions.model ?? analysisModel, - cwd: sdkOptions.cwd ?? projectPath, - maxTurns: sdkOptions.maxTurns, - allowedTools: sdkOptions.allowedTools as string[], - abortController, - settingSources: sdkOptions.settingSources, - thinkingLevel: analysisThinkingLevel, // Pass thinking level - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration - }; - - const stream = provider.executeQuery(options); - let analysisResult = ''; - - for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - analysisResult = block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId: analysisFeatureId, - content: block.text, - projectPath, - }); - } - } - } else if (msg.type === 'result' && msg.subtype === 'success') { - analysisResult = msg.result || analysisResult; - } - } - - // Save analysis to .automaker directory - const automakerDir = getAutomakerDir(projectPath); - const analysisPath = path.join(automakerDir, 'project-analysis.md'); - await secureFs.mkdir(automakerDir, { recursive: true }); - await secureFs.writeFile(analysisPath, analysisResult); - - this.emitAutoModeEvent('auto_mode_feature_complete', { - featureId: analysisFeatureId, - featureName: 'Project Analysis', - branchName: null, // Project analysis is not worktree-specific - passes: true, - message: 'Project analysis completed', - projectPath, - }); - } catch (error) { - const errorInfo = classifyError(error); - this.emitAutoModeEvent('auto_mode_error', { - featureId: analysisFeatureId, - featureName: 'Project Analysis', - branchName: null, // Project analysis is not worktree-specific - error: errorInfo.message, - errorType: errorInfo.type, - projectPath, - }); - } - } - - /** - * Get current status - */ - getStatus(): { - isRunning: boolean; - runningFeatures: string[]; - runningCount: number; - } { - return { - isRunning: this.runningFeatures.size > 0, - runningFeatures: Array.from(this.runningFeatures.keys()), - runningCount: this.runningFeatures.size, - }; - } - - /** - * Get status for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name, or null for main worktree - */ - getStatusForProject( - projectPath: string, - branchName: string | null = null - ): { - isAutoLoopRunning: boolean; - runningFeatures: string[]; - runningCount: number; - maxConcurrency: number; - branchName: string | null; - } { - const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); - const projectState = this.autoLoopsByProject.get(worktreeKey); - const runningFeatures: string[] = []; - - for (const [featureId, feature] of this.runningFeatures) { - // Filter by project path AND branchName to get worktree-specific features - if (feature.projectPath === projectPath && feature.branchName === branchName) { - runningFeatures.push(featureId); - } - } - - return { - isAutoLoopRunning: projectState?.isRunning ?? false, - runningFeatures, - runningCount: runningFeatures.length, - maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, - branchName, - }; - } - - /** - * Get all active auto loop worktrees with their project paths and branch names - */ - getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { - const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; - for (const [, state] of this.autoLoopsByProject) { - if (state.isRunning) { - activeWorktrees.push({ - projectPath: state.config.projectPath, - branchName: state.branchName, - }); - } - } - return activeWorktrees; - } - - /** - * Get all projects that have auto mode running (legacy, returns unique project paths) - * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information - */ - getActiveAutoLoopProjects(): string[] { - const activeProjects = new Set(); - for (const [, state] of this.autoLoopsByProject) { - if (state.isRunning) { - activeProjects.add(state.config.projectPath); - } - } - return Array.from(activeProjects); - } - - /** - * Get detailed info about all running agents - */ - async getRunningAgents(): Promise< - Array<{ - featureId: string; - projectPath: string; - projectName: string; - isAutoMode: boolean; - model?: string; - provider?: ModelProvider; - title?: string; - description?: string; - branchName?: string; - }> - > { - const agents = await Promise.all( - Array.from(this.runningFeatures.values()).map(async (rf) => { - // Try to fetch feature data to get title, description, and branchName - let title: string | undefined; - let description: string | undefined; - let branchName: string | undefined; - - try { - const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); - if (feature) { - title = feature.title; - description = feature.description; - branchName = feature.branchName; - } - } catch (error) { - // Silently ignore errors - title/description/branchName are optional - } - - return { - featureId: rf.featureId, - projectPath: rf.projectPath, - projectName: path.basename(rf.projectPath), - isAutoMode: rf.isAutoMode, - model: rf.model, - provider: rf.provider, - title, - description, - branchName, - }; - }) - ); - return agents; - } - - /** - * Wait for plan approval from the user. - * Returns a promise that resolves when the user approves/rejects the plan. - * Times out after 30 minutes to prevent indefinite memory retention. - */ - waitForPlanApproval( - featureId: string, - projectPath: string - ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { - const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - - logger.info(`Registering pending approval for feature ${featureId}`); - logger.info( - `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - return new Promise((resolve, reject) => { - // Set up timeout to prevent indefinite waiting and memory leaks - const timeoutId = setTimeout(() => { - const pending = this.pendingApprovals.get(featureId); - if (pending) { - logger.warn(`Plan approval for feature ${featureId} timed out after 30 minutes`); - this.pendingApprovals.delete(featureId); - reject( - new Error('Plan approval timed out after 30 minutes - feature execution cancelled') - ); - } - }, APPROVAL_TIMEOUT_MS); - - // Wrap resolve/reject to clear timeout when approval is resolved - const wrappedResolve = (result: { - approved: boolean; - editedPlan?: string; - feedback?: string; - }) => { - clearTimeout(timeoutId); - resolve(result); - }; - - const wrappedReject = (error: Error) => { - clearTimeout(timeoutId); - reject(error); - }; - - this.pendingApprovals.set(featureId, { - resolve: wrappedResolve, - reject: wrappedReject, - featureId, - projectPath, - }); - logger.info(`Pending approval registered for feature ${featureId} (timeout: 30 minutes)`); - }); - } - - /** - * Resolve a pending plan approval. - * Called when the user approves or rejects the plan via API. - */ - async resolvePlanApproval( - featureId: string, - approved: boolean, - editedPlan?: string, - feedback?: string, - projectPathFromClient?: string - ): Promise<{ success: boolean; error?: string }> { - logger.info(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`); - logger.info( - `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - const pending = this.pendingApprovals.get(featureId); - - if (!pending) { - logger.info(`No pending approval in Map for feature ${featureId}`); - - // RECOVERY: If no pending approval but we have projectPath from client, - // check if feature's planSpec.status is 'generated' and handle recovery - if (projectPathFromClient) { - logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`); - const feature = await this.loadFeature(projectPathFromClient, featureId); - - if (feature?.planSpec?.status === 'generated') { - logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`); - - if (approved) { - // Update planSpec to approved - await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: 'approved', - approvedAt: new Date().toISOString(), - reviewedByUser: true, - content: editedPlan || feature.planSpec.content, - }); - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build continuation prompt using centralized template - const planContent = editedPlan || feature.planSpec.content || ''; - let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; - continuationPrompt = continuationPrompt.replace( - /\{\{userFeedback\}\}/g, - feedback || '' - ); - continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); - - logger.info(`Starting recovery execution for feature ${featureId}`); - - // Start feature execution with the continuation prompt (async, don't await) - // Pass undefined for providedWorktreePath, use options for continuation prompt - this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { - continuationPrompt, - }).catch((error) => { - logger.error(`Recovery execution failed for feature ${featureId}:`, error); - }); - - return { success: true }; - } else { - // Rejected - update status and emit event - await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { - status: 'rejected', - reviewedByUser: true, - }); - - await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog'); - - this.emitAutoModeEvent('plan_rejected', { - featureId, - projectPath: projectPathFromClient, - feedback, - }); - - return { success: true }; - } - } - } - - logger.info( - `ERROR: No pending approval found for feature ${featureId} and recovery not possible` - ); - return { - success: false, - error: `No pending approval for feature ${featureId}`, - }; - } - logger.info(`Found pending approval for feature ${featureId}, proceeding...`); - - const { projectPath } = pending; - - // Update feature's planSpec status - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: approved ? 'approved' : 'rejected', - approvedAt: approved ? new Date().toISOString() : undefined, - reviewedByUser: true, - content: editedPlan, // Update content if user provided an edited version - }); - - // If rejected with feedback, we can store it for the user to see - if (!approved && feedback) { - // Emit event so client knows the rejection reason - this.emitAutoModeEvent('plan_rejected', { - featureId, - projectPath, - feedback, - }); - } - - // Resolve the promise with all data including feedback - pending.resolve({ approved, editedPlan, feedback }); - this.pendingApprovals.delete(featureId); - - return { success: true }; - } - - /** - * Cancel a pending plan approval (e.g., when feature is stopped). - */ - cancelPlanApproval(featureId: string): void { - logger.info(`cancelPlanApproval called for feature ${featureId}`); - logger.info( - `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` - ); - const pending = this.pendingApprovals.get(featureId); - if (pending) { - logger.info(`Found and cancelling pending approval for feature ${featureId}`); - pending.reject(new Error('Plan approval cancelled - feature was stopped')); - this.pendingApprovals.delete(featureId); - } else { - logger.info(`No pending approval to cancel for feature ${featureId}`); - } - } - - /** - * Check if a feature has a pending plan approval. - */ - hasPendingApproval(featureId: string): boolean { - return this.pendingApprovals.has(featureId); - } - - // Private helpers - - /** - * Find an existing worktree for a given branch by checking git worktree list - */ - private async findExistingWorktreeForBranch( - projectPath: string, - branchName: string - ): Promise { - try { - const { stdout } = await execAsync('git worktree list --porcelain', { - cwd: projectPath, - }); - - const lines = stdout.split('\n'); - let currentPath: string | null = null; - let currentBranch: string | null = null; - - for (const line of lines) { - if (line.startsWith('worktree ')) { - currentPath = line.slice(9); - } else if (line.startsWith('branch ')) { - currentBranch = line.slice(7).replace('refs/heads/', ''); - } else if (line === '' && currentPath && currentBranch) { - // End of a worktree entry - if (currentBranch === branchName) { - // Resolve to absolute path - git may return relative paths - // On Windows, this is critical for cwd to work correctly - // On all platforms, absolute paths ensure consistent behavior - const resolvedPath = path.isAbsolute(currentPath) - ? path.resolve(currentPath) - : path.resolve(projectPath, currentPath); - return resolvedPath; - } - currentPath = null; - currentBranch = null; - } - } - - // Check the last entry (if file doesn't end with newline) - if (currentPath && currentBranch && currentBranch === branchName) { - // Resolve to absolute path for cross-platform compatibility - const resolvedPath = path.isAbsolute(currentPath) - ? path.resolve(currentPath) - : path.resolve(projectPath, currentPath); - return resolvedPath; - } - - return null; - } catch { - return null; - } - } - - private async loadFeature(projectPath: string, featureId: string): Promise { - // Features are stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; - return JSON.parse(data); - } catch { - return null; - } - } - - private async updateFeatureStatus( - projectPath: string, - featureId: string, - status: string - ): Promise { - // Features are stored in .automaker directory - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature) { - logger.warn(`Feature ${featureId} not found or could not be recovered`); - return; - } - - feature.status = status; - feature.updatedAt = new Date().toISOString(); - // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) - // Badge will show for 2 minutes after this timestamp - if (status === 'waiting_approval') { - feature.justFinishedAt = new Date().toISOString(); - } else { - // Clear the timestamp when moving to other statuses - feature.justFinishedAt = undefined; - } - - // Use atomic write with backup support - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - - // Create notifications for important status changes - const notificationService = getNotificationService(); - if (status === 'waiting_approval') { - await notificationService.createNotification({ - type: 'feature_waiting_approval', - title: 'Feature Ready for Review', - message: `"${feature.name || featureId}" is ready for your review and approval.`, - featureId, - projectPath, - }); - } else if (status === 'verified') { - await notificationService.createNotification({ - type: 'feature_verified', - title: 'Feature Verified', - message: `"${feature.name || featureId}" has been verified and is complete.`, - featureId, - projectPath, - }); - } - - // Sync completed/verified features to app_spec.txt - if (status === 'verified' || status === 'completed') { - try { - await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); - } catch (syncError) { - // Log but don't fail the status update if sync fails - logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); - } - } - } catch (error) { - logger.error(`Failed to update feature status for ${featureId}:`, error); - } - } - - /** - * Mark a feature as interrupted due to server restart or other interruption. - * - * This is a convenience helper that updates the feature status to 'interrupted', - * indicating the feature was in progress but execution was disrupted (e.g., server - * restart, process crash, or manual stop). Features with this status can be - * resumed later using the resume functionality. - * - * Note: Features with pipeline_* statuses are preserved rather than overwritten - * to 'interrupted'. This ensures that resumePipelineFeature() can pick up from - * the correct pipeline step after a restart. - * - * @param projectPath - Path to the project - * @param featureId - ID of the feature to mark as interrupted - * @param reason - Optional reason for the interruption (logged for debugging) - */ - async markFeatureInterrupted( - projectPath: string, - featureId: string, - reason?: string - ): Promise { - // Load the feature to check its current status - const feature = await this.loadFeature(projectPath, featureId); - const currentStatus = feature?.status; - - // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step - if (currentStatus && currentStatus.startsWith('pipeline_')) { - logger.info( - `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` - ); - return; - } - - if (reason) { - logger.info(`Marking feature ${featureId} as interrupted: ${reason}`); - } else { - logger.info(`Marking feature ${featureId} as interrupted`); - } - - await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); - } - - /** - * Mark all currently running features as interrupted. - * - * This method is called during graceful server shutdown to ensure that all - * features currently being executed are properly marked as 'interrupted'. - * This allows them to be detected and resumed when the server restarts. - * - * @param reason - Optional reason for the interruption (logged for debugging) - * @returns Promise that resolves when all features have been marked as interrupted - */ - async markAllRunningFeaturesInterrupted(reason?: string): Promise { - const runningCount = this.runningFeatures.size; - - if (runningCount === 0) { - logger.info('No running features to mark as interrupted'); - return; - } - - const logReason = reason || 'server shutdown'; - logger.info(`Marking ${runningCount} running feature(s) as interrupted due to: ${logReason}`); - - const markPromises: Promise[] = []; - - for (const [featureId, runningFeature] of this.runningFeatures) { - markPromises.push( - this.markFeatureInterrupted(runningFeature.projectPath, featureId, logReason).catch( - (error) => { - logger.error(`Failed to mark feature ${featureId} as interrupted:`, error); - } - ) - ); - } - - await Promise.all(markPromises); - - logger.info(`Finished marking ${runningCount} feature(s) as interrupted`); - } - - private isFeatureFinished(feature: Feature): boolean { - const isCompleted = feature.status === 'completed' || feature.status === 'verified'; - - // Even if marked as completed, if it has an approved plan with pending tasks, it's not finished - if (feature.planSpec?.status === 'approved') { - const tasksCompleted = feature.planSpec.tasksCompleted ?? 0; - const tasksTotal = feature.planSpec.tasksTotal ?? 0; - if (tasksCompleted < tasksTotal) { - return false; - } - } - - return isCompleted; - } - - /** - * Check if a feature is currently running (being executed or resumed). - * This is used for idempotent checks to prevent race conditions when - * multiple callers try to resume the same feature simultaneously. - * - * @param featureId - The ID of the feature to check - * @returns true if the feature is currently running, false otherwise - */ - isFeatureRunning(featureId: string): boolean { - return this.runningFeatures.has(featureId); - } - - /** - * Update the planSpec of a feature - */ - private async updateFeaturePlanSpec( - projectPath: string, - featureId: string, - updates: Partial - ): Promise { - // Use getFeatureDir helper for consistent path resolution - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature) { - logger.warn(`Feature ${featureId} not found or could not be recovered`); - return; - } - - // Initialize planSpec if it doesn't exist - if (!feature.planSpec) { - feature.planSpec = { - status: 'pending', - version: 1, - reviewedByUser: false, - }; - } - - // Apply updates - Object.assign(feature.planSpec, updates); - - // If content is being updated and it's a new version, increment version - if (updates.content && updates.content !== feature.planSpec.content) { - feature.planSpec.version = (feature.planSpec.version || 0) + 1; - } - - feature.updatedAt = new Date().toISOString(); - - // Use atomic write with backup support - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - } catch (error) { - logger.error(`Failed to update planSpec for ${featureId}:`, error); - } - } - - /** - * Save the extracted summary to a feature's summary field. - * This is called after agent execution completes to save a summary - * extracted from the agent's output using tags. - * - * Note: This is different from updateFeatureSummary which updates - * the description field during plan generation. - * - * @param projectPath - The project path - * @param featureId - The feature ID - * @param summary - The summary text to save - */ - private async saveFeatureSummary( - projectPath: string, - featureId: string, - summary: string - ): Promise { - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature) { - logger.warn(`Feature ${featureId} not found or could not be recovered`); - return; - } - - feature.summary = summary; - feature.updatedAt = new Date().toISOString(); - - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - - this.emitAutoModeEvent('auto_mode_summary', { - featureId, - projectPath, - summary, - }); - } catch (error) { - logger.error(`Failed to save summary for ${featureId}:`, error); - } - } - - /** - * Update the status of a specific task within planSpec.tasks - */ - private async updateTaskStatus( - projectPath: string, - featureId: string, - taskId: string, - status: ParsedTask['status'] - ): Promise { - // Use getFeatureDir helper for consistent path resolution - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature || !feature.planSpec?.tasks) { - logger.warn(`Feature ${featureId} not found or has no tasks`); - return; - } - - // Find and update the task - const task = feature.planSpec.tasks.find((t) => t.id === taskId); - if (task) { - task.status = status; - feature.updatedAt = new Date().toISOString(); - - // Use atomic write with backup support - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - - // Emit event for UI update - this.emitAutoModeEvent('auto_mode_task_status', { - featureId, - projectPath, - taskId, - status, - tasks: feature.planSpec.tasks, - }); - } - } catch (error) { - logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); - } - } - - /** - * Update the description of a feature based on extracted summary from plan content. - * This is called when a plan is generated during spec/full planning modes. - * - * Only updates the description if it's short (<50 chars), same as title, - * or starts with generic verbs like "implement/add/create/fix/update". - * - * Note: This is different from saveFeatureSummary which saves to the - * separate summary field after agent execution. - * - * @param projectPath - The project path - * @param featureId - The feature ID - * @param summary - The summary text extracted from the plan - */ - private async updateFeatureSummary( - projectPath: string, - featureId: string, - summary: string - ): Promise { - const featureDir = getFeatureDir(projectPath, featureId); - const featurePath = path.join(featureDir, 'feature.json'); - - try { - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${featureId}`, logger); - - const feature = result.data; - if (!feature) { - logger.warn(`Feature ${featureId} not found`); - return; - } - - // Only update if the feature doesn't already have a detailed description - // (Don't overwrite user-provided descriptions with extracted summaries) - const currentDesc = feature.description || ''; - const isShortOrGeneric = - currentDesc.length < 50 || - currentDesc === feature.title || - /^(implement|add|create|fix|update)\s/i.test(currentDesc); - - if (isShortOrGeneric) { - feature.description = summary; - feature.updatedAt = new Date().toISOString(); - - await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - logger.info(`Updated feature ${featureId} description with extracted summary`); - } - } catch (error) { - logger.error(`Failed to update summary for ${featureId}:`, error); - } - } - - /** - * Load pending features for a specific project/worktree - * @param projectPath - The project path - * @param branchName - The branch name to filter by, or null for main worktree (features without branchName) - */ - private async loadPendingFeatures( - projectPath: string, - branchName: string | null = null - ): Promise { - // Features are stored in .automaker directory - const featuresDir = getFeaturesDir(projectPath); - - // Get the actual primary branch name for the project (e.g., "main", "master", "develop") - // This is needed to correctly match features when branchName is null (main worktree) - const primaryBranch = await getCurrentBranch(projectPath); - - try { - const entries = await secureFs.readdir(featuresDir, { - withFileTypes: true, - }); - const allFeatures: Feature[] = []; - const pendingFeatures: Feature[] = []; - - // Load all features (for dependency checking) with recovery support - for (const entry of entries) { - if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${entry.name}`, logger); - - const feature = result.data; - if (!feature) { - // Skip features that couldn't be loaded or recovered - continue; - } - - allFeatures.push(feature); - - // Track pending features separately, filtered by worktree/branch - // Note: waiting_approval is NOT included - those features have completed execution - // and are waiting for user review, they should not be picked up again - // - // Recovery cases: - // 1. Standard pending/ready/backlog statuses - // 2. Features with approved plans that have incomplete tasks (crash recovery) - // 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery) - // 4. Features with 'generating' planSpec status (spec generation was interrupted) - const needsRecovery = - feature.status === 'pending' || - feature.status === 'ready' || - feature.status === 'backlog' || - feature.status === 'in_progress' || // Recover features that were in progress when server crashed - feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown - (feature.planSpec?.status === 'approved' && - (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) || - feature.planSpec?.status === 'generating'; // Recover interrupted spec generation - - if (needsRecovery) { - // Filter by branchName: - // - If branchName is null (main worktree), include features with: - // - branchName === null, OR - // - branchName === primaryBranch (e.g., "main", "master", "develop") - // - If branchName is set, only include features with matching branchName - const featureBranch = feature.branchName ?? null; - if (branchName === null) { - // Main worktree: include features without branchName OR with branchName matching primary branch - // This handles repos where the primary branch is named something other than "main" - const isPrimaryBranch = - featureBranch === null || (primaryBranch && featureBranch === primaryBranch); - if (isPrimaryBranch) { - pendingFeatures.push(feature); - } else { - logger.debug( - `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree` - ); - } - } else { - // Feature worktree: include features with matching branchName - if (featureBranch === branchName) { - pendingFeatures.push(feature); - } else { - logger.debug( - `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}` - ); - } - } - } - } - } - - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}` - ); - - if (pendingFeatures.length === 0) { - logger.warn( - `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` - ); - // Log all backlog features to help debug branchName matching - const allBacklogFeatures = allFeatures.filter( - (f) => - f.status === 'backlog' || - f.status === 'pending' || - f.status === 'ready' || - (f.planSpec?.status === 'approved' && - (f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0)) - ); - if (allBacklogFeatures.length > 0) { - logger.info( - `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}` - ); - } - } - - // Apply dependency-aware ordering - const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures); - - // Remove missing dependencies from features and save them - // This allows features to proceed when their dependencies have been deleted or don't exist - if (missingDependencies.size > 0) { - for (const [featureId, missingDepIds] of missingDependencies) { - const feature = pendingFeatures.find((f) => f.id === featureId); - if (feature && feature.dependencies) { - // Filter out the missing dependency IDs - const validDependencies = feature.dependencies.filter( - (depId) => !missingDepIds.includes(depId) - ); - - logger.warn( - `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.` - ); - - // Update the feature in memory - feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined; - - // Save the updated feature to disk - try { - await this.featureLoader.update(projectPath, featureId, { - dependencies: feature.dependencies, - }); - logger.info( - `[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies` - ); - } catch (error) { - logger.error( - `[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`, - error - ); - } - } - } - } - - // Get skipVerificationInAutoMode setting - const settings = await this.settingsService?.getGlobalSettings(); - const skipVerification = settings?.skipVerificationInAutoMode ?? false; - - // Filter to only features with satisfied dependencies - const readyFeatures: Feature[] = []; - const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; - - for (const feature of orderedFeatures) { - const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); - if (isSatisfied) { - readyFeatures.push(feature); - } else { - // Find which dependencies are blocking - const blockingDeps = - feature.dependencies?.filter((depId) => { - const dep = allFeatures.find((f) => f.id === depId); - if (!dep) return true; // Missing dependency - if (skipVerification) { - return dep.status === 'running'; - } - return dep.status !== 'completed' && dep.status !== 'verified'; - }) || []; - blockedFeatures.push({ - feature, - reason: - blockingDeps.length > 0 - ? `Blocked by dependencies: ${blockingDeps.join(', ')}` - : 'Unknown dependency issue', - }); - } - } - - if (blockedFeatures.length > 0) { - logger.info( - `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}` - ); - } - - logger.info( - `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})` - ); - - return readyFeatures; - } catch (error) { - logger.error(`[loadPendingFeatures] Error loading features:`, error); - return []; - } - } - - /** - * Extract a title from feature description (first line or truncated) - */ - private extractTitleFromDescription(description: string): string { - if (!description || !description.trim()) { - return 'Untitled Feature'; - } - - // Get first line, or first 60 characters if no newline - const firstLine = description.split('\n')[0].trim(); - if (firstLine.length <= 60) { - return firstLine; - } - - // Truncate to 60 characters and add ellipsis - return firstLine.substring(0, 57) + '...'; - } - - /** - * Generate a comprehensive commit message for a feature - * Includes title, description summary, and file statistics - */ - private async generateCommitMessage(feature: Feature, workDir: string): Promise { - const title = this.extractTitleFromDescription(feature.description); - - // Extract description summary (first 3-5 lines, up to 300 chars) - let descriptionSummary = ''; - if (feature.description && feature.description.trim()) { - const lines = feature.description.split('\n').filter((l) => l.trim()); - const summaryLines = lines.slice(0, 5); // First 5 non-empty lines - descriptionSummary = summaryLines.join('\n'); - - // Limit to 300 characters - if (descriptionSummary.length > 300) { - descriptionSummary = descriptionSummary.substring(0, 297) + '...'; - } - } - - // Get file statistics to add context - let fileStats = ''; - try { - const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir }); - if (diffStat.trim()) { - // Extract just the summary line (last line with file count) - const statLines = diffStat.trim().split('\n'); - const summaryLine = statLines[statLines.length - 1]; - if (summaryLine && summaryLine.includes('file')) { - fileStats = `\n${summaryLine.trim()}`; - } - } - } catch { - // Ignore errors getting stats - } - - // Build commit message - let message = `feat: ${title}`; - - if (descriptionSummary && descriptionSummary !== title) { - message += `\n\n${descriptionSummary}`; - } - - if (fileStats) { - message += fileStats; - } - - message += '\n\nImplemented by Automaker auto-mode'; - - return message; - } - - /** - * Get the planning prompt prefix based on feature's planning mode - */ - private async getPlanningPromptPrefix(feature: Feature): Promise { - const mode = feature.planningMode || 'skip'; - - if (mode === 'skip') { - return ''; // No planning phase - } - - // Load prompts from settings (no caching - allows hot reload of custom prompts) - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - const planningPrompts: Record = { - lite: prompts.autoMode.planningLite, - lite_with_approval: prompts.autoMode.planningLiteWithApproval, - spec: prompts.autoMode.planningSpec, - full: prompts.autoMode.planningFull, - }; - - // For lite mode, use the approval variant if requirePlanApproval is true - let promptKey: string = mode; - if (mode === 'lite' && feature.requirePlanApproval === true) { - promptKey = 'lite_with_approval'; - } - - const planningPrompt = planningPrompts[promptKey]; - if (!planningPrompt) { - return ''; - } - - return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; - } - - private buildFeaturePrompt( - feature: Feature, - taskExecutionPrompts: { - implementationInstructions: string; - playwrightVerificationInstructions: string; - } - ): string { - const title = this.extractTitleFromDescription(feature.description); - - let prompt = `## Feature Implementation Task - -**Feature ID:** ${feature.id} -**Title:** ${title} -**Description:** ${feature.description} -`; - - if (feature.spec) { - prompt += ` -**Specification:** -${feature.spec} -`; - } - - // Add images note (like old implementation) - if (feature.imagePaths && feature.imagePaths.length > 0) { - const imagesList = feature.imagePaths - .map((img, idx) => { - const path = typeof img === 'string' ? img : img.path; - const filename = - typeof img === 'string' ? path.split('/').pop() : img.filename || path.split('/').pop(); - const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; - return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; - }) - .join('\n'); - - prompt += ` -**📎 Context Images Attached:** -The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read: - -${imagesList} - -You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing. -`; - } - - // Add verification instructions based on testing mode - if (feature.skipTests) { - // Manual verification - just implement the feature - prompt += `\n${taskExecutionPrompts.implementationInstructions}`; - } else { - // Automated testing - implement and verify with Playwright - prompt += `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; - } - - return prompt; - } - - private async runAgent( - workDir: string, - featureId: string, - prompt: string, - abortController: AbortController, - projectPath: string, - imagePaths?: string[], - model?: string, - options?: { - projectPath?: string; - planningMode?: PlanningMode; - requirePlanApproval?: boolean; - previousContent?: string; - systemPrompt?: string; - autoLoadClaudeMd?: boolean; - thinkingLevel?: ThinkingLevel; - branchName?: string | null; - } - ): Promise { - const finalProjectPath = options?.projectPath || projectPath; - const branchName = options?.branchName ?? null; - const planningMode = options?.planningMode || 'skip'; - const previousContent = options?.previousContent; - - // Validate vision support before processing images - const effectiveModel = model || 'claude-sonnet-4-20250514'; - if (imagePaths && imagePaths.length > 0) { - const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel); - if (!supportsVision) { - throw new Error( - `This model (${effectiveModel}) does not support image input. ` + - `Please switch to a model that supports vision (like Claude models), or remove the images and try again.` - ); - } - } - - // Check if this planning mode can generate a spec/plan that needs approval - // - spec and full always generate specs - // - lite only generates approval-ready content when requirePlanApproval is true - const planningModeRequiresApproval = - planningMode === 'spec' || - planningMode === 'full' || - (planningMode === 'lite' && options?.requirePlanApproval === true); - const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; - - // Check if feature already has an approved plan with tasks (recovery scenario) - // If so, we should skip spec detection and use persisted task status - let existingApprovedPlan: Feature['planSpec'] | undefined; - let persistedTasks: ParsedTask[] | undefined; - if (planningModeRequiresApproval) { - const feature = await this.loadFeature(projectPath, featureId); - if (feature?.planSpec?.status === 'approved' && feature.planSpec.tasks) { - existingApprovedPlan = feature.planSpec; - persistedTasks = feature.planSpec.tasks; - logger.info( - `Recovery: Using persisted tasks for feature ${featureId} (${persistedTasks.length} tasks, ${persistedTasks.filter((t) => t.status === 'completed').length} completed)` - ); - } - } - - // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set - // This prevents actual API calls during automated testing - if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { - logger.info(`MOCK MODE: Skipping real agent execution for feature ${featureId}`); - - // Simulate some work being done - await this.sleep(500); - - // Emit mock progress events to simulate agent activity - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Analyzing the codebase...', - }); - - await this.sleep(300); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: 'Mock agent: Implementing the feature...', - }); - - await this.sleep(300); - - // Create a mock file with "yellow" content as requested in the test - const mockFilePath = path.join(workDir, 'yellow.txt'); - await secureFs.writeFile(mockFilePath, 'yellow'); - - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: "Mock agent: Created yellow.txt file with content 'yellow'", - }); - - await this.sleep(200); - - // Save mock agent output - const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, 'agent-output.md'); - - const mockOutput = `# Mock Agent Output - -## Summary -This is a mock agent response for CI/CD testing. - -## Changes Made -- Created \`yellow.txt\` with content "yellow" - -## Notes -This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. -`; - - await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); - await secureFs.writeFile(outputPath, mockOutput); - - logger.info(`MOCK MODE: Completed mock execution for feature ${featureId}`); - return; - } - - // Load autoLoadClaudeMd setting (project setting takes precedence over global) - // Use provided value if available, otherwise load from settings - const autoLoadClaudeMd = - options?.autoLoadClaudeMd !== undefined - ? options.autoLoadClaudeMd - : await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]'); - - // Load MCP servers from settings (global setting only) - const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); - - // Load MCP permission settings (global setting only) - - // Build SDK options using centralized configuration for feature implementation - const sdkOptions = createAutoModeOptions({ - cwd: workDir, - model: model, - abortController, - autoLoadClaudeMd, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - thinkingLevel: options?.thinkingLevel, - }); - - // Extract model, maxTurns, and allowedTools from SDK options - const finalModel = sdkOptions.model!; - const maxTurns = sdkOptions.maxTurns; - const allowedTools = sdkOptions.allowedTools as string[] | undefined; - - logger.info( - `runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` - ); - - // Get provider for this model - const provider = ProviderFactory.getProviderForModel(finalModel); - - // Strip provider prefix - providers should receive bare model IDs - const bareModel = stripProviderPrefix(finalModel); - - logger.info( - `Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})` - ); - - // Build prompt content with images using utility - const { content: promptContent } = await buildPromptWithImages( - prompt, - imagePaths, - workDir, - false // don't duplicate paths in text - ); - - // Debug: Log if system prompt is provided - if (options?.systemPrompt) { - logger.info( - `System prompt provided (${options.systemPrompt.length} chars), first 200 chars:\n${options.systemPrompt.substring(0, 200)}...` - ); - } - - // Get credentials for API calls (model comes from request, no phase model) - const credentials = await this.settingsService?.getCredentials(); - - // Try to find a provider for the model (if it's a provider model like "GLM-4.7") - // This allows users to select provider models in the Auto Mode / Feature execution - let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; - let providerResolvedModel: string | undefined; - if (finalModel && this.settingsService) { - const providerResult = await getProviderByModelId( - finalModel, - this.settingsService, - '[AutoMode]' - ); - if (providerResult.provider) { - claudeCompatibleProvider = providerResult.provider; - providerResolvedModel = providerResult.resolvedModel; - logger.info( - `[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` + - (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') - ); - } - } - - // Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel - const effectiveBareModel = providerResolvedModel - ? stripProviderPrefix(providerResolvedModel) - : bareModel; - - const executeOptions: ExecuteOptions = { - prompt: promptContent, - model: effectiveBareModel, - maxTurns: maxTurns, - cwd: workDir, - allowedTools: allowedTools, - abortController, - systemPrompt: sdkOptions.systemPrompt, - settingSources: sdkOptions.settingSources, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration - thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.) - }; - - // Execute via provider - logger.info(`Starting stream for feature ${featureId}...`); - const stream = provider.executeQuery(executeOptions); - logger.info(`Stream created, starting to iterate...`); - // Initialize with previous content if this is a follow-up, with a separator - let responseText = previousContent - ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` - : ''; - // Skip spec detection if we already have an approved plan (recovery scenario) - let specDetected = !!existingApprovedPlan; - - // Agent output goes to .automaker directory - // Note: We use projectPath here, not workDir, because workDir might be a worktree path - const featureDirForOutput = getFeatureDir(projectPath, featureId); - const outputPath = path.join(featureDirForOutput, 'agent-output.md'); - const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); - - // Raw output logging is configurable via environment variable - // Set AUTOMAKER_DEBUG_RAW_OUTPUT=true to enable raw stream event logging - const enableRawOutput = - process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || - process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; - - // Incremental file writing state - let writeTimeout: ReturnType | null = null; - const WRITE_DEBOUNCE_MS = 500; // Batch writes every 500ms - - // Raw output accumulator for debugging (NDJSON format) - let rawOutputLines: string[] = []; - let rawWriteTimeout: ReturnType | null = null; - - // Helper to append raw stream event for debugging (only when enabled) - const appendRawEvent = (event: unknown): void => { - if (!enableRawOutput) return; - - try { - const timestamp = new Date().toISOString(); - const rawLine = JSON.stringify({ timestamp, event }, null, 4); // Pretty print for readability - rawOutputLines.push(rawLine); - - // Debounced write of raw output - if (rawWriteTimeout) { - clearTimeout(rawWriteTimeout); - } - rawWriteTimeout = setTimeout(async () => { - try { - await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); - await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); - rawOutputLines = []; // Clear after writing - } catch (error) { - logger.error(`Failed to write raw output for ${featureId}:`, error); - } - }, WRITE_DEBOUNCE_MS); - } catch { - // Ignore serialization errors - } - }; - - // Helper to write current responseText to file - const writeToFile = async (): Promise => { - try { - await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); - await secureFs.writeFile(outputPath, responseText); - } catch (error) { - // Log but don't crash - file write errors shouldn't stop execution - logger.error(`Failed to write agent output for ${featureId}:`, error); - } - }; - - // Debounced write - schedules a write after WRITE_DEBOUNCE_MS - const scheduleWrite = (): void => { - if (writeTimeout) { - clearTimeout(writeTimeout); - } - writeTimeout = setTimeout(() => { - writeToFile(); - }, WRITE_DEBOUNCE_MS); - }; - - // Heartbeat logging so "silent" model calls are visible. - // Some runs can take a while before the first streamed message arrives. - const streamStartTime = Date.now(); - let receivedAnyStreamMessage = false; - const STREAM_HEARTBEAT_MS = 15_000; - const streamHeartbeat = setInterval(() => { - if (receivedAnyStreamMessage) return; - const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000); - logger.info( - `Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...` - ); - }, STREAM_HEARTBEAT_MS); - - // RECOVERY PATH: If we have an approved plan with persisted tasks, skip spec generation - // and directly execute the remaining tasks - if (existingApprovedPlan && persistedTasks && persistedTasks.length > 0) { - logger.info( - `Recovery: Resuming task execution for feature ${featureId} with ${persistedTasks.length} tasks` - ); - - // Get customized prompts for task execution - const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - const approvedPlanContent = existingApprovedPlan.content || ''; - - // Execute each task with a separate agent - for (let taskIndex = 0; taskIndex < persistedTasks.length; taskIndex++) { - const task = persistedTasks[taskIndex]; - - // Skip tasks that are already completed - if (task.status === 'completed') { - logger.info(`Skipping already completed task ${task.id}`); - continue; - } - - // Check for abort - if (abortController.signal.aborted) { - throw new Error('Feature execution aborted'); - } - - // Mark task as in_progress immediately (even without TASK_START marker) - await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); - - // Emit task started - logger.info(`Starting task ${task.id}: ${task.description}`); - this.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - branchName, - taskId: task.id, - taskDescription: task.description, - taskIndex, - tasksTotal: persistedTasks.length, - }); - - // Update planSpec with current task - await this.updateFeaturePlanSpec(projectPath, featureId, { - currentTaskId: task.id, - }); - - // Build focused prompt for this specific task - const taskPrompt = this.buildTaskPrompt( - task, - persistedTasks, - taskIndex, - approvedPlanContent, - taskPrompts.taskExecution.taskPromptTemplate, - undefined - ); - - // Execute task with dedicated agent - const taskStream = provider.executeQuery({ - prompt: taskPrompt, - model: effectiveBareModel, - maxTurns: Math.min(maxTurns || 100, 50), - cwd: workDir, - allowedTools: allowedTools, - abortController, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - credentials, - claudeCompatibleProvider, - }); - - let taskOutput = ''; - let taskCompleteDetected = false; - - // Process task stream - for await (const msg of taskStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - const text = block.text || ''; - taskOutput += text; - responseText += text; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: text, - }); - scheduleWrite(); - - // Detect [TASK_COMPLETE] marker - if (!taskCompleteDetected) { - const completeTaskId = detectTaskCompleteMarker(taskOutput); - if (completeTaskId) { - taskCompleteDetected = true; - logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); - await this.updateTaskStatus( - projectPath, - featureId, - completeTaskId, - 'completed' - ); - } - } - } else if (block.type === 'tool_use') { - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - branchName, - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || `Error during task ${task.id}`); - } else if (msg.type === 'result' && msg.subtype === 'success') { - taskOutput += msg.result || ''; - responseText += msg.result || ''; - } - } - - // If no [TASK_COMPLETE] marker was detected, still mark as completed - if (!taskCompleteDetected) { - await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); - } - - // Emit task completed - logger.info(`Task ${task.id} completed for feature ${featureId}`); - this.emitAutoModeEvent('auto_mode_task_complete', { - featureId, - projectPath, - branchName, - taskId: task.id, - tasksCompleted: taskIndex + 1, - tasksTotal: persistedTasks.length, - }); - - // Update planSpec with progress - await this.updateFeaturePlanSpec(projectPath, featureId, { - tasksCompleted: taskIndex + 1, - }); - } - - logger.info(`Recovery: All tasks completed for feature ${featureId}`); - - // Extract and save final summary - // Note: saveFeatureSummary already emits auto_mode_summary event - const summary = extractSummary(responseText); - if (summary) { - await this.saveFeatureSummary(projectPath, featureId, summary); - } - - // Final write and cleanup - clearInterval(streamHeartbeat); - if (writeTimeout) { - clearTimeout(writeTimeout); - } - await writeToFile(); - return; - } - - // Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort - try { - streamLoop: for await (const msg of stream) { - receivedAnyStreamMessage = true; - // Log raw stream event for debugging - appendRawEvent(msg); - - logger.info(`Stream message received:`, msg.type, msg.subtype || ''); - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - const newText = block.text || ''; - - // Skip empty text - if (!newText) continue; - - // Note: Cursor-specific dedup (duplicate blocks, accumulated text) is now - // handled in CursorProvider.deduplicateTextBlocks() for cleaner separation - - // Only add separator when we're at a natural paragraph break: - // - Previous text ends with sentence terminator AND new text starts a new thought - // - Don't add separators mid-word or mid-sentence (for streaming providers like Cursor) - if (responseText.length > 0 && newText.length > 0) { - const lastChar = responseText.slice(-1); - const endsWithSentence = /[.!?:]\s*$/.test(responseText); - const endsWithNewline = /\n\s*$/.test(responseText); - const startsNewParagraph = /^[\n#\-*>]/.test(newText); - - // Add paragraph break only at natural boundaries - if ( - !endsWithNewline && - (endsWithSentence || startsNewParagraph) && - !/[a-zA-Z0-9]/.test(lastChar) // Not mid-word - ) { - responseText += '\n\n'; - } - } - responseText += newText; - - // Check for authentication errors in the response - if ( - block.text && - (block.text.includes('Invalid API key') || - block.text.includes('authentication_failed') || - block.text.includes('Fix external API key')) - ) { - throw new Error( - 'Authentication failed: Invalid or expired API key. ' + - "Please check your ANTHROPIC_API_KEY, or run 'claude login' to re-authenticate." - ); - } - - // Schedule incremental file write (debounced) - scheduleWrite(); - - // Check for [SPEC_GENERATED] marker in planning modes (spec or full) - // Also support fallback detection for non-Claude models that may not output the marker - const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'); - const hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); - if ( - planningModeRequiresApproval && - !specDetected && - (hasExplicitMarker || hasFallbackSpec) - ) { - specDetected = true; - - // Extract plan content (everything before the marker, or full content for fallback) - let planContent: string; - if (hasExplicitMarker) { - const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); - planContent = responseText.substring(0, markerIndex).trim(); - } else { - // Fallback: use all accumulated content as the plan - planContent = responseText.trim(); - logger.info( - `Using fallback spec detection for feature ${featureId} (no [SPEC_GENERATED] marker)` - ); - } - - // Parse tasks from the generated spec (for spec and full modes) - // Use let since we may need to update this after plan revision - let parsedTasks = parseTasksFromSpec(planContent); - const tasksTotal = parsedTasks.length; - - logger.info(`Parsed ${tasksTotal} tasks from spec for feature ${featureId}`); - if (parsedTasks.length > 0) { - logger.info(`Tasks: ${parsedTasks.map((t) => t.id).join(', ')}`); - } - - // Update planSpec status to 'generated' and save content with parsed tasks - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generated', - content: planContent, - version: 1, - generatedAt: new Date().toISOString(), - reviewedByUser: false, - tasks: parsedTasks, - tasksTotal, - tasksCompleted: 0, - }); - - // Extract and save summary from the plan content - const planSummary = extractSummary(planContent); - if (planSummary) { - logger.info(`Extracted summary from plan: ${planSummary.substring(0, 100)}...`); - // Update the feature with the extracted summary - await this.updateFeatureSummary(projectPath, featureId, planSummary); - } - - let approvedPlanContent = planContent; - let userFeedback: string | undefined; - let currentPlanContent = planContent; - let planVersion = 1; - - // Only pause for approval if requirePlanApproval is true - if (requiresApproval) { - // ======================================== - // PLAN REVISION LOOP - // Keep regenerating plan until user approves - // ======================================== - let planApproved = false; - - while (!planApproved) { - logger.info( - `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` - ); - - // CRITICAL: Register pending approval BEFORE emitting event - const approvalPromise = this.waitForPlanApproval(featureId, projectPath); - - // Emit plan_approval_required event - this.emitAutoModeEvent('plan_approval_required', { - featureId, - projectPath, - branchName, - planContent: currentPlanContent, - planningMode, - planVersion, - }); - - // Wait for user response - try { - const approvalResult = await approvalPromise; - - if (approvalResult.approved) { - // User approved the plan - logger.info(`Plan v${planVersion} approved for feature ${featureId}`); - planApproved = true; - - // If user provided edits, use the edited version - if (approvalResult.editedPlan) { - approvedPlanContent = approvalResult.editedPlan; - await this.updateFeaturePlanSpec(projectPath, featureId, { - content: approvalResult.editedPlan, - }); - } else { - approvedPlanContent = currentPlanContent; - } - - // Capture any additional feedback for implementation - userFeedback = approvalResult.feedback; - - // Emit approval event - this.emitAutoModeEvent('plan_approved', { - featureId, - projectPath, - branchName, - hasEdits: !!approvalResult.editedPlan, - planVersion, - }); - } else { - // User rejected - check if they provided feedback for revision - const hasFeedback = - approvalResult.feedback && approvalResult.feedback.trim().length > 0; - const hasEdits = - approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0; - - if (!hasFeedback && !hasEdits) { - // No feedback or edits = explicit cancel - logger.info( - `Plan rejected without feedback for feature ${featureId}, cancelling` - ); - throw new Error('Plan cancelled by user'); - } - - // User wants revisions - regenerate the plan - logger.info( - `Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...` - ); - planVersion++; - - // Emit revision event - this.emitAutoModeEvent('plan_revision_requested', { - featureId, - projectPath, - branchName, - feedback: approvalResult.feedback, - hasEdits: !!hasEdits, - planVersion, - }); - - // Build revision prompt using customizable template - const revisionPrompts = await getPromptCustomization( - this.settingsService, - '[AutoMode]' - ); - - // Get task format example based on planning mode - const taskFormatExample = - planningMode === 'full' - ? `\`\`\`tasks -## Phase 1: Foundation -- [ ] T001: [Description] | File: [path/to/file] -- [ ] T002: [Description] | File: [path/to/file] - -## Phase 2: Core Implementation -- [ ] T003: [Description] | File: [path/to/file] -- [ ] T004: [Description] | File: [path/to/file] -\`\`\`` - : `\`\`\`tasks -- [ ] T001: [Description] | File: [path/to/file] -- [ ] T002: [Description] | File: [path/to/file] -- [ ] T003: [Description] | File: [path/to/file] -\`\`\``; - - let revisionPrompt = revisionPrompts.taskExecution.planRevisionTemplate; - revisionPrompt = revisionPrompt.replace( - /\{\{planVersion\}\}/g, - String(planVersion - 1) - ); - revisionPrompt = revisionPrompt.replace( - /\{\{previousPlan\}\}/g, - hasEdits - ? approvalResult.editedPlan || currentPlanContent - : currentPlanContent - ); - revisionPrompt = revisionPrompt.replace( - /\{\{userFeedback\}\}/g, - approvalResult.feedback || - 'Please revise the plan based on the edits above.' - ); - revisionPrompt = revisionPrompt.replace( - /\{\{planningMode\}\}/g, - planningMode - ); - revisionPrompt = revisionPrompt.replace( - /\{\{taskFormatExample\}\}/g, - taskFormatExample - ); - - // Update status to regenerating - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generating', - version: planVersion, - }); - - // Make revision call - const revisionStream = provider.executeQuery({ - prompt: revisionPrompt, - model: effectiveBareModel, - maxTurns: maxTurns || 100, - cwd: workDir, - allowedTools: allowedTools, - abortController, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider, // Pass provider for alternative endpoint configuration - }); - - let revisionText = ''; - for await (const msg of revisionStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - revisionText += block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - content: block.text, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || 'Error during plan revision'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - revisionText += msg.result || ''; - } - } - - // Extract new plan content - const markerIndex = revisionText.indexOf('[SPEC_GENERATED]'); - if (markerIndex > 0) { - currentPlanContent = revisionText.substring(0, markerIndex).trim(); - } else { - currentPlanContent = revisionText.trim(); - } - - // Re-parse tasks from revised plan - const revisedTasks = parseTasksFromSpec(currentPlanContent); - logger.info(`Revised plan has ${revisedTasks.length} tasks`); - - // Warn if no tasks found in spec/full mode - this may cause fallback to single-agent - if ( - revisedTasks.length === 0 && - (planningMode === 'spec' || planningMode === 'full') - ) { - logger.warn( - `WARNING: Revised plan in ${planningMode} mode has no tasks! ` + - `This will cause fallback to single-agent execution. ` + - `The AI may have omitted the required \`\`\`tasks block.` - ); - this.emitAutoModeEvent('plan_revision_warning', { - featureId, - projectPath, - branchName, - planningMode, - warning: - 'Revised plan missing tasks block - will use single-agent execution', - }); - } - - // Update planSpec with revised content - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'generated', - content: currentPlanContent, - version: planVersion, - tasks: revisedTasks, - tasksTotal: revisedTasks.length, - tasksCompleted: 0, - }); - - // Update parsedTasks for implementation - parsedTasks = revisedTasks; - - responseText += revisionText; - } - } catch (error) { - if ((error as Error).message.includes('cancelled')) { - throw error; - } - throw new Error(`Plan approval failed: ${(error as Error).message}`); - } - } - } else { - // Auto-approve: requirePlanApproval is false, just continue without pausing - logger.info( - `Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)` - ); - - // Emit info event for frontend - this.emitAutoModeEvent('plan_auto_approved', { - featureId, - projectPath, - branchName, - planContent, - planningMode, - }); - - approvedPlanContent = planContent; - } - - // CRITICAL: After approval, we need to make a second call to continue implementation - // The agent is waiting for "approved" - we need to send it and continue - logger.info( - `Making continuation call after plan approval for feature ${featureId}` - ); - - // Update planSpec status to approved (handles both manual and auto-approval paths) - await this.updateFeaturePlanSpec(projectPath, featureId, { - status: 'approved', - approvedAt: new Date().toISOString(), - reviewedByUser: requiresApproval, - }); - - // ======================================== - // MULTI-AGENT TASK EXECUTION - // Each task gets its own focused agent call - // ======================================== - - if (parsedTasks.length > 0) { - logger.info( - `Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}` - ); - - // Get customized prompts for task execution - const taskPrompts = await getPromptCustomization( - this.settingsService, - '[AutoMode]' - ); - - // Execute each task with a separate agent - for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { - const task = parsedTasks[taskIndex]; - - // Skip tasks that are already completed (for recovery after restart) - if (task.status === 'completed') { - logger.info(`Skipping already completed task ${task.id}`); - continue; - } - - // Check for abort - if (abortController.signal.aborted) { - throw new Error('Feature execution aborted'); - } - - // Mark task as in_progress immediately (even without TASK_START marker) - await this.updateTaskStatus(projectPath, featureId, task.id, 'in_progress'); - - // Emit task started - logger.info(`Starting task ${task.id}: ${task.description}`); - this.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - branchName, - taskId: task.id, - taskDescription: task.description, - taskIndex, - tasksTotal: parsedTasks.length, - }); - - // Update planSpec with current task - await this.updateFeaturePlanSpec(projectPath, featureId, { - currentTaskId: task.id, - }); - - // Build focused prompt for this specific task - const taskPrompt = this.buildTaskPrompt( - task, - parsedTasks, - taskIndex, - approvedPlanContent, - taskPrompts.taskExecution.taskPromptTemplate, - userFeedback - ); - - // Execute task with dedicated agent - const taskStream = provider.executeQuery({ - prompt: taskPrompt, - model: effectiveBareModel, - maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task - cwd: workDir, - allowedTools: allowedTools, - abortController, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider, // Pass provider for alternative endpoint configuration - }); - - let taskOutput = ''; - let taskStartDetected = false; - let taskCompleteDetected = false; - - // Process task stream - for await (const msg of taskStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - const text = block.text || ''; - taskOutput += text; - responseText += text; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: text, - }); - - // Detect [TASK_START] marker - if (!taskStartDetected) { - const startTaskId = detectTaskStartMarker(taskOutput); - if (startTaskId) { - taskStartDetected = true; - logger.info(`[TASK_START] detected for ${startTaskId}`); - // Update task status to in_progress in planSpec.tasks - await this.updateTaskStatus( - projectPath, - featureId, - startTaskId, - 'in_progress' - ); - this.emitAutoModeEvent('auto_mode_task_started', { - featureId, - projectPath, - branchName, - taskId: startTaskId, - taskDescription: task.description, - taskIndex, - tasksTotal: parsedTasks.length, - }); - } - } - - // Detect [TASK_COMPLETE] marker - if (!taskCompleteDetected) { - const completeTaskId = detectTaskCompleteMarker(taskOutput); - if (completeTaskId) { - taskCompleteDetected = true; - logger.info(`[TASK_COMPLETE] detected for ${completeTaskId}`); - // Update task status to completed in planSpec.tasks - await this.updateTaskStatus( - projectPath, - featureId, - completeTaskId, - 'completed' - ); - } - } - - // Detect [PHASE_COMPLETE] marker - const phaseNumber = detectPhaseCompleteMarker(text); - if (phaseNumber !== null) { - logger.info(`[PHASE_COMPLETE] detected for Phase ${phaseNumber}`); - this.emitAutoModeEvent('auto_mode_phase_complete', { - featureId, - projectPath, - branchName, - phaseNumber, - }); - } - } else if (block.type === 'tool_use') { - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - branchName, - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || `Error during task ${task.id}`); - } else if (msg.type === 'result' && msg.subtype === 'success') { - taskOutput += msg.result || ''; - responseText += msg.result || ''; - } - } - - // If no [TASK_COMPLETE] marker was detected, still mark as completed - // (for models that don't output markers) - if (!taskCompleteDetected) { - await this.updateTaskStatus(projectPath, featureId, task.id, 'completed'); - } - - // Emit task completed - logger.info(`Task ${task.id} completed for feature ${featureId}`); - this.emitAutoModeEvent('auto_mode_task_complete', { - featureId, - projectPath, - branchName, - taskId: task.id, - tasksCompleted: taskIndex + 1, - tasksTotal: parsedTasks.length, - }); - - // Update planSpec with progress - await this.updateFeaturePlanSpec(projectPath, featureId, { - tasksCompleted: taskIndex + 1, - }); - - // Check for phase completion (group tasks by phase) - if (task.phase) { - const nextTask = parsedTasks[taskIndex + 1]; - if (!nextTask || nextTask.phase !== task.phase) { - // Phase changed, emit phase complete - const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); - if (phaseMatch) { - this.emitAutoModeEvent('auto_mode_phase_complete', { - featureId, - projectPath, - branchName, - phaseNumber: parseInt(phaseMatch[1], 10), - }); - } - } - } - } - - logger.info(`All ${parsedTasks.length} tasks completed for feature ${featureId}`); - } else { - // No parsed tasks - fall back to single-agent execution - logger.info( - `No parsed tasks, using single-agent execution for feature ${featureId}` - ); - - // Get customized prompts for continuation - const taskPrompts = await getPromptCustomization( - this.settingsService, - '[AutoMode]' - ); - let continuationPrompt = - taskPrompts.taskExecution.continuationAfterApprovalTemplate; - continuationPrompt = continuationPrompt.replace( - /\{\{userFeedback\}\}/g, - userFeedback || '' - ); - continuationPrompt = continuationPrompt.replace( - /\{\{approvedPlan\}\}/g, - approvedPlanContent - ); - - const continuationStream = provider.executeQuery({ - prompt: continuationPrompt, - model: effectiveBareModel, - maxTurns: maxTurns, - cwd: workDir, - allowedTools: allowedTools, - abortController, - mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - credentials, // Pass credentials for resolving 'credentials' apiKeySource - claudeCompatibleProvider, // Pass provider for alternative endpoint configuration - }); - - for await (const msg of continuationStream) { - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text || ''; - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: block.text, - }); - } else if (block.type === 'tool_use') { - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - branchName, - tool: block.name, - input: block.input, - }); - } - } - } else if (msg.type === 'error') { - throw new Error(msg.error || 'Unknown error during implementation'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText += msg.result || ''; - } - } - } - - // Extract and save final summary from multi-task or single-agent execution - // Note: saveFeatureSummary already emits auto_mode_summary event - const summary = extractSummary(responseText); - if (summary) { - await this.saveFeatureSummary(projectPath, featureId, summary); - } - - logger.info(`Implementation completed for feature ${featureId}`); - // Exit the original stream loop since continuation is done - break streamLoop; - } - - // Only emit progress for non-marker text (marker was already handled above) - if (!specDetected) { - logger.info( - `Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}` - ); - this.emitAutoModeEvent('auto_mode_progress', { - featureId, - branchName, - content: block.text, - }); - } - } else if (block.type === 'tool_use') { - // Emit event for real-time UI - this.emitAutoModeEvent('auto_mode_tool', { - featureId, - branchName, - tool: block.name, - input: block.input, - }); - - // Also add to file output for persistence - if (responseText.length > 0 && !responseText.endsWith('\n')) { - responseText += '\n'; - } - responseText += `\n🔧 Tool: ${block.name}\n`; - if (block.input) { - responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; - } - scheduleWrite(); - } - } - } else if (msg.type === 'error') { - // Handle error messages - throw new Error(msg.error || 'Unknown error'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - // Don't replace responseText - the accumulated content is the full history - // The msg.result is just a summary which would lose all tool use details - // Just ensure final write happens - scheduleWrite(); - } - } - - // Final write - ensure all accumulated content is saved (on success path) - await writeToFile(); - - // Flush remaining raw output (only if enabled, on success path) - if (enableRawOutput && rawOutputLines.length > 0) { - try { - await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); - await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); - } catch (error) { - logger.error(`Failed to write final raw output for ${featureId}:`, error); - } - } - } finally { - clearInterval(streamHeartbeat); - // ALWAYS clear pending timeouts to prevent memory leaks - // This runs on success, error, or abort - if (writeTimeout) { - clearTimeout(writeTimeout); - writeTimeout = null; - } - if (rawWriteTimeout) { - clearTimeout(rawWriteTimeout); - rawWriteTimeout = null; - } - } - } - - private async executeFeatureWithContext( - projectPath: string, - featureId: string, - context: string, - useWorktrees: boolean - ): Promise { - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build the feature prompt - const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); - - // Use the resume feature template with variable substitution - let prompt = prompts.taskExecution.resumeFeatureTemplate; - prompt = prompt.replace(/\{\{featurePrompt\}\}/g, featurePrompt); - prompt = prompt.replace(/\{\{previousContext\}\}/g, context); - - return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { - continuationPrompt: prompt, - _calledInternally: true, - }); - } - - /** - * Detect if a feature is stuck in a pipeline step and extract step information. - * Parses the feature status to determine if it's a pipeline status (e.g., 'pipeline_step_xyz'), - * loads the pipeline configuration, and validates that the step still exists. - * - * This method handles several scenarios: - * - Non-pipeline status: Returns default PipelineStatusInfo with isPipeline=false - * - Invalid pipeline status format: Returns isPipeline=true but null step info - * - Step deleted from config: Returns stepIndex=-1 to signal missing step - * - Valid pipeline step: Returns full step information and config - * - * @param {string} projectPath - Absolute path to the project directory - * @param {string} featureId - Unique identifier of the feature - * @param {FeatureStatusWithPipeline} currentStatus - Current feature status (may include pipeline step info) - * @returns {Promise} Information about the pipeline status and step - * @private - */ - private async detectPipelineStatus( - projectPath: string, - featureId: string, - currentStatus: FeatureStatusWithPipeline - ): Promise { - // Check if status is pipeline format using PipelineService - const isPipeline = pipelineService.isPipelineStatus(currentStatus); - - if (!isPipeline) { - return { - isPipeline: false, - stepId: null, - stepIndex: -1, - totalSteps: 0, - step: null, - config: null, - }; - } - - // Extract step ID using PipelineService - const stepId = pipelineService.getStepIdFromStatus(currentStatus); - - if (!stepId) { - console.warn( - `[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}` - ); - return { - isPipeline: true, - stepId: null, - stepIndex: -1, - totalSteps: 0, - step: null, - config: null, - }; - } - - // Load pipeline config - const config = await pipelineService.getPipelineConfig(projectPath); - - if (!config || config.steps.length === 0) { - // Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status - console.warn( - `[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists` - ); - return { - isPipeline: true, - stepId, - stepIndex: -1, - totalSteps: 0, - step: null, - config: null, - }; - } - - // Find the step directly from config (already loaded, avoid redundant file read) - const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); - const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); - const step = stepIndex === -1 ? null : sortedSteps[stepIndex]; - - if (!step) { - // Step not found in current config - step was deleted/changed - console.warn( - `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` - ); - return { - isPipeline: true, - stepId, - stepIndex: -1, - totalSteps: sortedSteps.length, - step: null, - config, - }; - } - - console.log( - `[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})` - ); - - return { - isPipeline: true, - stepId, - stepIndex, - totalSteps: sortedSteps.length, - step, - config, - }; - } - - /** - * Build a focused prompt for executing a single task. - * Each task gets minimal context to keep the agent focused. - */ - private buildTaskPrompt( - task: ParsedTask, - allTasks: ParsedTask[], - taskIndex: number, - planContent: string, - taskPromptTemplate: string, - userFeedback?: string - ): string { - const completedTasks = allTasks.slice(0, taskIndex); - const remainingTasks = allTasks.slice(taskIndex + 1); - - // Build completed tasks string - const completedTasksStr = - completedTasks.length > 0 - ? `### Already Completed (${completedTasks.length} tasks)\n${completedTasks.map((t) => `- [x] ${t.id}: ${t.description}`).join('\n')}\n` - : ''; - - // Build remaining tasks string - const remainingTasksStr = - remainingTasks.length > 0 - ? `### Coming Up Next (${remainingTasks.length} tasks remaining)\n${remainingTasks - .slice(0, 3) - .map((t) => `- [ ] ${t.id}: ${t.description}`) - .join( - '\n' - )}${remainingTasks.length > 3 ? `\n... and ${remainingTasks.length - 3} more tasks` : ''}\n` - : ''; - - // Build user feedback string - const userFeedbackStr = userFeedback ? `### User Feedback\n${userFeedback}\n` : ''; - - // Use centralized template with variable substitution - let prompt = taskPromptTemplate; - prompt = prompt.replace(/\{\{taskId\}\}/g, task.id); - prompt = prompt.replace(/\{\{taskDescription\}\}/g, task.description); - prompt = prompt.replace(/\{\{taskFilePath\}\}/g, task.filePath || ''); - prompt = prompt.replace(/\{\{taskPhase\}\}/g, task.phase || ''); - prompt = prompt.replace(/\{\{completedTasks\}\}/g, completedTasksStr); - prompt = prompt.replace(/\{\{remainingTasks\}\}/g, remainingTasksStr); - prompt = prompt.replace(/\{\{userFeedback\}\}/g, userFeedbackStr); - prompt = prompt.replace(/\{\{planContent\}\}/g, planContent); - - return prompt; - } - - /** - * Emit an auto-mode event wrapped in the correct format for the client. - * All auto-mode events are sent as type "auto-mode:event" with the actual - * event type and data in the payload. - */ - private emitAutoModeEvent(eventType: string, data: Record): void { - // Wrap the event in auto-mode:event format expected by the client - this.events.emit('auto-mode:event', { - type: eventType, - ...data, - }); - } - - private sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, ms); - - // If signal is provided and already aborted, reject immediately - if (signal?.aborted) { - clearTimeout(timeout); - reject(new Error('Aborted')); - return; - } - - // Listen for abort signal - if (signal) { - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeout); - reject(new Error('Aborted')); - }, - { once: true } - ); - } - }); - } - - // ============================================================================ - // Execution State Persistence - For recovery after server restart - // ============================================================================ - - /** - * Save execution state to disk for recovery after server restart - */ - private async saveExecutionState(projectPath: string): Promise { - try { - await ensureAutomakerDir(projectPath); - const statePath = getExecutionStatePath(projectPath); - const state: ExecutionState = { - version: 1, - autoLoopWasRunning: this.autoLoopRunning, - maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, - projectPath, - branchName: null, // Legacy global auto mode uses main worktree - runningFeatureIds: Array.from(this.runningFeatures.keys()), - savedAt: new Date().toISOString(), - }; - await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); - logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); - } catch (error) { - logger.error('Failed to save execution state:', error); - } - } - - /** - * Load execution state from disk - */ - private async loadExecutionState(projectPath: string): Promise { - try { - const statePath = getExecutionStatePath(projectPath); - const content = (await secureFs.readFile(statePath, 'utf-8')) as string; - const state = JSON.parse(content) as ExecutionState; - return state; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error('Failed to load execution state:', error); - } - return DEFAULT_EXECUTION_STATE; - } - } - - /** - * Clear execution state (called on successful shutdown or when auto-loop stops) - */ - private async clearExecutionState( - projectPath: string, - branchName: string | null = null - ): Promise { - try { - const statePath = getExecutionStatePath(projectPath); - await secureFs.unlink(statePath); - const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - logger.info(`Cleared execution state for ${worktreeDesc}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error('Failed to clear execution state:', error); - } - } - } - - /** - * Check for and resume interrupted features after server restart - * This should be called during server initialization - */ - async resumeInterruptedFeatures(projectPath: string): Promise { - logger.info('Checking for interrupted features to resume...'); - - // Load all features and find those that were interrupted - const featuresDir = getFeaturesDir(projectPath); - - try { - const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); - // Track features with and without context separately for better logging - const featuresWithContext: Feature[] = []; - const featuresWithoutContext: Feature[] = []; - - for (const entry of entries) { - if (entry.isDirectory()) { - const featurePath = path.join(featuresDir, entry.name, 'feature.json'); - - // Use recovery-enabled read for corrupted file handling - const result = await readJsonWithRecovery(featurePath, null, { - maxBackups: DEFAULT_BACKUP_COUNT, - autoRestore: true, - }); - - logRecoveryWarning(result, `Feature ${entry.name}`, logger); - - const feature = result.data; - if (!feature) { - // Skip features that couldn't be loaded or recovered - continue; - } - - // Check if feature was interrupted (in_progress/interrupted or pipeline_*) - if ( - feature.status === 'in_progress' || - feature.status === 'interrupted' || - (feature.status && feature.status.startsWith('pipeline_')) - ) { - // Check if context (agent-output.md) exists - const featureDir = getFeatureDir(projectPath, feature.id); - const contextPath = path.join(featureDir, 'agent-output.md'); - try { - await secureFs.access(contextPath); - featuresWithContext.push(feature); - logger.info( - `Found interrupted feature with context: ${feature.id} (${feature.title}) - status: ${feature.status}` - ); - } catch { - // No context file - feature was interrupted before any agent output - // Still include it for resumption (will start fresh) - featuresWithoutContext.push(feature); - logger.info( - `Found interrupted feature without context: ${feature.id} (${feature.title}) - status: ${feature.status} (will restart fresh)` - ); - } - } - } - } - - // Combine all interrupted features (with and without context) - const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext]; - - if (allInterruptedFeatures.length === 0) { - logger.info('No interrupted features found'); - return; - } - - logger.info( - `Found ${allInterruptedFeatures.length} interrupted feature(s) to resume ` + - `(${featuresWithContext.length} with context, ${featuresWithoutContext.length} without context)` - ); - - // Emit event to notify UI with context information - this.emitAutoModeEvent('auto_mode_resuming_features', { - message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s) after server restart`, - projectPath, - featureIds: allInterruptedFeatures.map((f) => f.id), - features: allInterruptedFeatures.map((f) => ({ - id: f.id, - title: f.title, - status: f.status, - branchName: f.branchName ?? null, - hasContext: featuresWithContext.some((fc) => fc.id === f.id), - })), - }); - - // Resume each interrupted feature - for (const feature of allInterruptedFeatures) { - try { - // Idempotent check: skip if feature is already being resumed (prevents race conditions) - if (this.isFeatureRunning(feature.id)) { - logger.info( - `Feature ${feature.id} (${feature.title}) is already being resumed, skipping` - ); - continue; - } - - const hasContext = featuresWithContext.some((fc) => fc.id === feature.id); - logger.info( - `Resuming feature: ${feature.id} (${feature.title}) - ${hasContext ? 'continuing from context' : 'starting fresh'}` - ); - // Use resumeFeature which will detect the existing context and continue, - // or start fresh if no context exists - await this.resumeFeature(projectPath, feature.id, true); - } catch (error) { - logger.error(`Failed to resume feature ${feature.id}:`, error); - // Continue with other features - } - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - logger.info('No features directory found, nothing to resume'); - } else { - logger.error('Error checking for interrupted features:', error); - } - } - } - - /** - * Extract and record learnings from a completed feature - * Uses a quick Claude call to identify important decisions and patterns - */ - private async recordLearningsFromFeature( - projectPath: string, - feature: Feature, - agentOutput: string - ): Promise { - if (!agentOutput || agentOutput.length < 100) { - // Not enough output to extract learnings from - console.log( - `[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)` - ); - return; - } - - console.log( - `[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)` - ); - - // Limit output to avoid token limits - const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput; - - // Get customized prompts from settings - const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - - // Build user prompt using centralized template with variable substitution - let userPrompt = prompts.taskExecution.learningExtractionUserPromptTemplate; - userPrompt = userPrompt.replace(/\{\{featureTitle\}\}/g, feature.title || ''); - userPrompt = userPrompt.replace(/\{\{implementationLog\}\}/g, truncatedOutput); - - try { - // Get model from phase settings - const settings = await this.settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel; - const { model } = resolvePhaseModel(phaseModelEntry); - const hasClaudeKey = Boolean(process.env.ANTHROPIC_API_KEY); - let resolvedModel = model; - - if (isClaudeModel(model) && !hasClaudeKey) { - const fallbackModel = feature.model - ? resolveModelString(feature.model, DEFAULT_MODELS.claude) - : null; - if (fallbackModel && !isClaudeModel(fallbackModel)) { - console.log( - `[AutoMode] Claude not configured for memory extraction; using feature model "${fallbackModel}".` - ); - resolvedModel = fallbackModel; - } else { - console.log( - '[AutoMode] Claude not configured for memory extraction; skipping learning extraction.' - ); - return; - } - } - - const result = await simpleQuery({ - prompt: userPrompt, - model: resolvedModel, - cwd: projectPath, - maxTurns: 1, - allowedTools: [], - systemPrompt: prompts.taskExecution.learningExtractionSystemPrompt, - }); - - const responseText = result.text; - - console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`); - console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`); - - // Parse the response - handle JSON in markdown code blocks or raw - let jsonStr: string | null = null; - - // First try to find JSON in markdown code blocks - const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); - if (codeBlockMatch) { - console.log('[AutoMode] Found JSON in code block'); - jsonStr = codeBlockMatch[1]; - } else { - // Fall back to finding balanced braces containing "learnings" - // Use a more precise approach: find the opening brace before "learnings" - const learningsIndex = responseText.indexOf('"learnings"'); - if (learningsIndex !== -1) { - // Find the opening brace before "learnings" - let braceStart = responseText.lastIndexOf('{', learningsIndex); - if (braceStart !== -1) { - // Find matching closing brace - let braceCount = 0; - let braceEnd = -1; - for (let i = braceStart; i < responseText.length; i++) { - if (responseText[i] === '{') braceCount++; - if (responseText[i] === '}') braceCount--; - if (braceCount === 0) { - braceEnd = i; - break; - } - } - if (braceEnd !== -1) { - jsonStr = responseText.substring(braceStart, braceEnd + 1); - } - } - } - } - - if (!jsonStr) { - console.log('[AutoMode] Could not extract JSON from response'); - return; - } - - console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`); - - let parsed: { learnings?: unknown[] }; - try { - parsed = JSON.parse(jsonStr); - } catch { - console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200)); - return; - } - - if (!parsed.learnings || !Array.isArray(parsed.learnings)) { - console.log('[AutoMode] No learnings array in parsed response'); - return; - } - - console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`); - - // Valid learning types - const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']); - - // Record each learning - for (const item of parsed.learnings) { - // Validate required fields with proper type narrowing - if (!item || typeof item !== 'object') continue; - - const learning = item as Record; - if ( - !learning.category || - typeof learning.category !== 'string' || - !learning.content || - typeof learning.content !== 'string' || - !learning.content.trim() - ) { - continue; - } - - // Validate and normalize type - const typeStr = typeof learning.type === 'string' ? learning.type : 'learning'; - const learningType = validTypes.has(typeStr) - ? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha') - : 'learning'; - - console.log( - `[AutoMode] Appending learning: category=${learning.category}, type=${learningType}` - ); - await appendLearning( - projectPath, - { - category: learning.category, - type: learningType, - content: learning.content.trim(), - context: typeof learning.context === 'string' ? learning.context : undefined, - why: typeof learning.why === 'string' ? learning.why : undefined, - rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined, - tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined, - breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined, - }, - secureFs as Parameters[2] - ); - } - - const validLearnings = parsed.learnings.filter( - (l) => l && typeof l === 'object' && (l as Record).content - ); - if (validLearnings.length > 0) { - console.log( - `[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}` - ); - } - } catch (error) { - console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error); - } - } - - /** - * Detect orphaned features - features whose branchName points to a branch that no longer exists. - * - * Orphaned features can occur when: - * - A feature branch is deleted after merge - * - A worktree is manually removed - * - A branch is force-deleted - * - * @param projectPath - Path to the project - * @returns Array of orphaned features with their missing branch names - */ - async detectOrphanedFeatures( - projectPath: string - ): Promise> { - const orphanedFeatures: Array<{ feature: Feature; missingBranch: string }> = []; - - try { - // Get all features for this project - const allFeatures = await this.featureLoader.getAll(projectPath); - - // Get features that have a branchName set (excludes main branch features) - const featuresWithBranches = allFeatures.filter( - (f) => f.branchName && f.branchName.trim() !== '' - ); - - if (featuresWithBranches.length === 0) { - logger.debug('[detectOrphanedFeatures] No features with branch names found'); - return orphanedFeatures; - } - - // Get all existing branches (local) - const existingBranches = await this.getExistingBranches(projectPath); - - // Get current/primary branch (features with null branchName are implicitly on this) - const primaryBranch = await getCurrentBranch(projectPath); - - // Check each feature with a branchName - for (const feature of featuresWithBranches) { - const branchName = feature.branchName!; - - // Skip if the branchName matches the primary branch (implicitly valid) - if (primaryBranch && branchName === primaryBranch) { - continue; - } - - // Check if the branch exists - if (!existingBranches.has(branchName)) { - orphanedFeatures.push({ - feature, - missingBranch: branchName, - }); - logger.info( - `[detectOrphanedFeatures] Found orphaned feature: ${feature.id} (${feature.title}) - branch "${branchName}" no longer exists` - ); - } - } - - if (orphanedFeatures.length > 0) { - logger.info( - `[detectOrphanedFeatures] Found ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` - ); - } else { - logger.debug('[detectOrphanedFeatures] No orphaned features found'); - } - - return orphanedFeatures; - } catch (error) { - logger.error('[detectOrphanedFeatures] Error detecting orphaned features:', error); - return orphanedFeatures; - } - } - - /** - * Get all existing local branches for a project - * @param projectPath - Path to the git repository - * @returns Set of branch names - */ - private async getExistingBranches(projectPath: string): Promise> { - const branches = new Set(); - - try { - // Use git for-each-ref to get all local branches - const { stdout } = await execAsync( - 'git for-each-ref --format="%(refname:short)" refs/heads/', - { cwd: projectPath } - ); - - const branchLines = stdout.trim().split('\n'); - for (const branch of branchLines) { - const trimmed = branch.trim(); - if (trimmed) { - branches.add(trimmed); - } - } - - logger.debug(`[getExistingBranches] Found ${branches.size} local branches`); - } catch (error) { - logger.error('[getExistingBranches] Failed to get branches:', error); - } - - return branches; - } -} diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts new file mode 100644 index 00000000..97fe19e8 --- /dev/null +++ b/apps/server/src/services/auto-mode/compat.ts @@ -0,0 +1,240 @@ +/** + * Compatibility Shim - Provides AutoModeService-like interface using the new architecture + * + * This allows existing routes to work without major changes during the transition. + * Routes receive this shim which delegates to GlobalAutoModeService and facades. + * + * This is a TEMPORARY shim - routes should be updated to use the new interface directly. + */ + +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '../../lib/events.js'; +import { GlobalAutoModeService } from './global-service.js'; +import { AutoModeServiceFacade } from './facade.js'; +import type { SettingsService } from '../settings-service.js'; +import type { FeatureLoader } from '../feature-loader.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; +import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js'; + +/** + * AutoModeServiceCompat wraps GlobalAutoModeService and facades to provide + * the old AutoModeService interface that routes expect. + */ +export class AutoModeServiceCompat { + private readonly globalService: GlobalAutoModeService; + private readonly facadeOptions: FacadeOptions; + private readonly facadeCache = new Map(); + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader, + claudeUsageService?: ClaudeUsageService | null + ) { + this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader); + const sharedServices = this.globalService.getSharedServices(); + + this.facadeOptions = { + events, + settingsService, + featureLoader, + sharedServices, + claudeUsageService: claudeUsageService ?? null, + }; + } + + /** + * Get the global service for direct access + */ + getGlobalService(): GlobalAutoModeService { + return this.globalService; + } + + /** + * Get or create a facade for a specific project. + * Facades are cached by project path so that auto loop state + * (stored in the facade's AutoLoopCoordinator) persists across API calls. + */ + createFacade(projectPath: string): AutoModeServiceFacade { + let facade = this.facadeCache.get(projectPath); + if (!facade) { + facade = AutoModeServiceFacade.create(projectPath, this.facadeOptions); + this.facadeCache.set(projectPath, facade); + } + return facade; + } + + // =========================================================================== + // GLOBAL OPERATIONS (delegated to GlobalAutoModeService) + // =========================================================================== + + getStatus(): AutoModeStatus { + return this.globalService.getStatus(); + } + + getActiveAutoLoopProjects(): string[] { + return this.globalService.getActiveAutoLoopProjects(); + } + + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.globalService.getActiveAutoLoopWorktrees(); + } + + async getRunningAgents(): Promise { + return this.globalService.getRunningAgents(); + } + + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + return this.globalService.markAllRunningFeaturesInterrupted(reason); + } + + async reconcileFeatureStates(projectPath: string): Promise { + return this.globalService.reconcileFeatureStates(projectPath); + } + + // =========================================================================== + // PER-PROJECT OPERATIONS (delegated to facades) + // =========================================================================== + + async getStatusForProject( + projectPath: string, + branchName: string | null = null + ): Promise<{ + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + branchName: string | null; + }> { + const facade = this.createFacade(projectPath); + return facade.getStatusForProject(branchName); + } + + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const facade = this.createFacade(projectPath); + return facade.isAutoLoopRunning(branchName); + } + + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const facade = this.createFacade(projectPath); + return facade.startAutoLoop(branchName, maxConcurrency); + } + + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const facade = this.createFacade(projectPath); + return facade.stopAutoLoop(branchName); + } + + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } + ): Promise { + const facade = this.createFacade(projectPath); + return facade.executeFeature( + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } + + async stopFeature(featureId: string): Promise { + // Stop feature is tricky - we need to find which project the feature is running in + // The concurrency manager tracks this + const runningAgents = await this.getRunningAgents(); + const agent = runningAgents.find((a) => a.featureId === featureId); + if (agent) { + const facade = this.createFacade(agent.projectPath); + return facade.stopFeature(featureId); + } + return false; + } + + async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeFeature(featureId, useWorktrees); + } + + async followUpFeature( + projectPath: string, + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + const facade = this.createFacade(projectPath); + return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees); + } + + async verifyFeature(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.verifyFeature(featureId); + } + + async commitFeature( + projectPath: string, + featureId: string, + providedWorktreePath?: string + ): Promise { + const facade = this.createFacade(projectPath); + return facade.commitFeature(featureId, providedWorktreePath); + } + + async contextExists(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.contextExists(featureId); + } + + async analyzeProject(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.analyzeProject(); + } + + async resolvePlanApproval( + projectPath: string, + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const facade = this.createFacade(projectPath); + return facade.resolvePlanApproval(featureId, approved, editedPlan, feedback); + } + + async resumeInterruptedFeatures(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeInterruptedFeatures(); + } + + async checkWorktreeCapacity( + projectPath: string, + featureId: string + ): Promise<{ + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; + }> { + const facade = this.createFacade(projectPath); + return facade.checkWorktreeCapacity(featureId); + } + + async detectOrphanedFeatures( + projectPath: string + ): Promise> { + const facade = this.createFacade(projectPath); + return facade.detectOrphanedFeatures(); + } +} diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts new file mode 100644 index 00000000..af660ea5 --- /dev/null +++ b/apps/server/src/services/auto-mode/facade.ts @@ -0,0 +1,1198 @@ +/** + * AutoModeServiceFacade - Clean interface for auto-mode functionality + * + * This facade provides a thin delegation layer over the extracted services, + * exposing all 23 public methods that routes currently call on AutoModeService. + * + * Key design decisions: + * - Per-project factory pattern (projectPath is implicit in method calls) + * - Clean method names (e.g., startAutoLoop instead of startAutoLoopForProject) + * - Thin delegation to underlying services - no new business logic + * - Maintains backward compatibility during transition period + */ + +import path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types'; +import { resolveModelString } from '@automaker/model-resolver'; +import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js'; +import { + getPromptCustomization, + getProviderByModelId, + getMCPServersFromSettings, + getDefaultMaxTurnsSetting, +} from '../../lib/settings-helpers.js'; +import { execGitCommand } from '@automaker/git-utils'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { PlanApprovalService } from '../plan-approval-service.js'; +import { AutoLoopCoordinator, type AutoModeConfig } from '../auto-loop-coordinator.js'; +import { ExecutionService } from '../execution-service.js'; +import { RecoveryService } from '../recovery-service.js'; +import { PipelineOrchestrator } from '../pipeline-orchestrator.js'; +import { AgentExecutor } from '../agent-executor.js'; +import { TestRunnerService } from '../test-runner-service.js'; +import { ProviderFactory } from '../../providers/provider-factory.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { EventEmitter } from '../../lib/events.js'; +import type { + FacadeOptions, + FacadeError, + AutoModeStatus, + ProjectAutoModeStatus, + WorktreeCapacityInfo, + RunningAgentInfo, + OrphanedFeatureInfo, +} from './types.js'; + +const execAsync = promisify(exec); +const logger = createLogger('AutoModeServiceFacade'); + +/** + * AutoModeServiceFacade provides a clean interface for auto-mode functionality. + * + * Created via factory pattern with a specific projectPath, allowing methods + * to use clean names without requiring projectPath as a parameter. + */ +export class AutoModeServiceFacade { + private constructor( + private readonly projectPath: string, + private readonly events: EventEmitter, + private readonly eventBus: TypedEventBus, + private readonly concurrencyManager: ConcurrencyManager, + private readonly worktreeResolver: WorktreeResolver, + private readonly featureStateManager: FeatureStateManager, + private readonly featureLoader: FeatureLoader, + private readonly planApprovalService: PlanApprovalService, + private readonly autoLoopCoordinator: AutoLoopCoordinator, + private readonly executionService: ExecutionService, + private readonly recoveryService: RecoveryService, + private readonly pipelineOrchestrator: PipelineOrchestrator, + private readonly settingsService: SettingsService | null + ) {} + + /** + * Classify and log an error at the facade boundary. + * Emits an error event to the UI so failures are surfaced to the user. + * + * @param error - The caught error + * @param method - The facade method name where the error occurred + * @param featureId - Optional feature ID for context + * @returns The classified FacadeError for structured consumption + */ + private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError { + const errorInfo = classifyError(error); + + // Log at the facade boundary for debugging + logger.error( + `[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`, + error + ); + + // Emit error event to UI unless it's an abort/cancellation + if (!errorInfo.isAbort && !errorInfo.isCancellation) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId: featureId ?? null, + featureName: undefined, + branchName: null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + + return { + method, + errorType: errorInfo.type, + message: errorInfo.message, + featureId, + projectPath: this.projectPath, + }; + } + + /** + * Create a new AutoModeServiceFacade instance for a specific project. + * + * @param projectPath - The project path this facade operates on + * @param options - Configuration options including events, settingsService, featureLoader + */ + static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade { + const { + events, + settingsService = null, + featureLoader = new FeatureLoader(), + sharedServices, + } = options; + + // Use shared services if provided, otherwise create new ones + // Shared services allow multiple facades to share state (e.g., running features, auto loops) + const eventBus = sharedServices?.eventBus ?? new TypedEventBus(events); + const worktreeResolver = sharedServices?.worktreeResolver ?? new WorktreeResolver(); + const concurrencyManager = + sharedServices?.concurrencyManager ?? + new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p)); + const featureStateManager = new FeatureStateManager(events, featureLoader); + const planApprovalService = new PlanApprovalService( + eventBus, + featureStateManager, + settingsService + ); + const agentExecutor = new AgentExecutor( + eventBus, + featureStateManager, + planApprovalService, + settingsService + ); + const testRunnerService = new TestRunnerService(); + + // Helper for building feature prompts (used by pipeline orchestrator) + const buildFeaturePrompt = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } + ): string => { + const title = + feature.title || feature.description?.split('\n')[0]?.substring(0, 60) || 'Untitled'; + let prompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${title}\n**Description:** ${feature.description}\n`; + if (feature.spec) { + prompt += `\n**Specification:**\n${feature.spec}\n`; + } + if (!feature.skipTests) { + prompt += `\n${prompts.implementationInstructions}\n\n${prompts.playwrightVerificationInstructions}`; + } else { + prompt += `\n${prompts.implementationInstructions}`; + } + return prompt; + }; + + // Create placeholder callbacks - will be bound to facade methods after creation. + // These use closures to capture the facade instance once created. + // INVARIANT: All callbacks passed to PipelineOrchestrator, AutoLoopCoordinator, + // and ExecutionService are invoked asynchronously (never during construction), + // so facadeInstance is guaranteed to be assigned before any callback runs. + let facadeInstance: AutoModeServiceFacade | null = null; + const getFacade = (): AutoModeServiceFacade => { + if (!facadeInstance) { + throw new Error( + 'AutoModeServiceFacade not yet initialized — callback invoked during construction' + ); + } + return facadeInstance; + }; + + /** + * Shared agent-run helper used by both PipelineOrchestrator and ExecutionService. + * + * Resolves the model string, looks up the custom provider/credentials via + * getProviderByModelId, then delegates to agentExecutor.execute with the + * full payload. The opts parameter uses an index-signature union so it + * accepts both the typed ExecutionService opts object and the looser + * Record used by PipelineOrchestrator without requiring + * type casts at the call sites. + */ + const createRunAgentFn = + () => + async ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + pPath: string, + imagePaths?: string[], + model?: string, + opts?: { + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + branchName?: string | null; + [key: string]: unknown; + } + ): Promise => { + const resolvedModel = resolveModelString(model, DEFAULT_MODELS.claude); + const provider = ProviderFactory.getProviderForModel(resolvedModel); + const effectiveBareModel = stripProviderPrefix(resolvedModel); + + // Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials + let claudeCompatibleProvider: + | import('@automaker/types').ClaudeCompatibleProvider + | undefined; + let credentials: import('@automaker/types').Credentials | undefined; + if (settingsService) { + const providerResult = await getProviderByModelId( + resolvedModel, + settingsService, + '[AutoModeFacade]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + credentials = providerResult.credentials; + } + } + + // Build sdkOptions with proper maxTurns and allowedTools for auto-mode. + // Without this, maxTurns would be undefined, causing providers to use their + // internal defaults which may be much lower than intended (e.g., Codex CLI's + // default turn limit can cause feature runs to stop prematurely). + const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false; + const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true; + let mcpServers: Record | undefined; + try { + if (settingsService) { + const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]'); + if (Object.keys(servers).length > 0) { + mcpServers = servers; + } + } + } catch { + // MCP servers are optional - continue without them + } + + // Read user-configured max turns from settings + const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]'); + + const sdkOpts = createAutoModeOptions({ + cwd: workDir, + model: resolvedModel, + systemPrompt: opts?.systemPrompt, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: opts?.thinkingLevel, + maxTurns: userMaxTurns, + mcpServers: mcpServers as + | Record + | undefined, + }); + + logger.info( + `[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` + + `maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` + + `provider=${provider.getName()}` + ); + + await agentExecutor.execute( + { + workDir, + featureId, + prompt, + projectPath: pPath, + abortController, + imagePaths, + model: resolvedModel, + planningMode: opts?.planningMode as PlanningMode | undefined, + requirePlanApproval: opts?.requirePlanApproval as boolean | undefined, + previousContent: opts?.previousContent as string | undefined, + systemPrompt: opts?.systemPrompt as string | undefined, + autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, + useClaudeCodeSystemPrompt, + thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, + reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined, + branchName: opts?.branchName as string | null | undefined, + provider, + effectiveBareModel, + credentials, + claudeCompatibleProvider, + mcpServers, + sdkOptions: { + maxTurns: sdkOpts.maxTurns, + allowedTools: sdkOpts.allowedTools as string[] | undefined, + systemPrompt: sdkOpts.systemPrompt, + settingSources: sdkOpts.settingSources as + | Array<'user' | 'project' | 'local'> + | undefined, + }, + }, + { + waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath), + saveFeatureSummary: (projPath, fId, summary) => + featureStateManager.saveFeatureSummary(projPath, fId, summary), + updateFeatureSummary: (projPath, fId, summary) => + featureStateManager.saveFeatureSummary(projPath, fId, summary), + buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => { + let taskPrompt = template + .replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`) + .replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1)) + .replace(/\{\{totalTasks\}\}/g, String(allTasks.length)) + .replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`); + if (feedback) { + taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); + } + return taskPrompt; + }, + } + ); + }; + + // PipelineOrchestrator - runAgentFn delegates to AgentExecutor via shared helper + const pipelineOrchestrator = new PipelineOrchestrator( + eventBus, + featureStateManager, + agentExecutor, + testRunnerService, + worktreeResolver, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + loadContextFiles, + buildFeaturePrompt, + (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => + getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), + createRunAgentFn() + ); + + // AutoLoopCoordinator - ALWAYS create new with proper execution callbacks + // NOTE: We don't use sharedServices.autoLoopCoordinator because it doesn't have + // execution callbacks. Each facade needs its own coordinator to execute features. + // The shared coordinator in GlobalAutoModeService is for monitoring only. + const autoLoopCoordinator = new AutoLoopCoordinator( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode) => + getFacade().executeFeature(featureId, useWorktrees, isAutoMode), + async (pPath, branchName) => { + const features = await featureLoader.getAll(pPath); + // For main worktree (branchName === null), resolve the actual primary branch name + // so features with branchName matching the primary branch are included + let primaryBranch: string | null = null; + if (branchName === null) { + primaryBranch = await worktreeResolver.getCurrentBranch(pPath); + } + return features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || (primaryBranch && f.branchName === primaryBranch) + : f.branchName === branchName) + ); + }, + (pPath, branchName, maxConcurrency) => + getFacade().saveExecutionStateForProject(branchName, maxConcurrency), + (pPath, branchName) => getFacade().clearExecutionState(branchName), + (pPath) => featureStateManager.resetStuckFeatures(pPath), + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + (featureId) => concurrencyManager.isRunning(featureId), + async (pPath) => featureLoader.getAll(pPath) + ); + + /** + * Iterate all active worktrees for this project, falling back to the + * main worktree (null) when none are active. + */ + const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => { + const projectWorktrees = autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === projectPath); + if (projectWorktrees.length === 0) { + fn(null); + } else { + for (const w of projectWorktrees) { + fn(w.branchName); + } + } + }; + + // ExecutionService - runAgentFn delegates to AgentExecutor via shared helper + const executionService = new ExecutionService( + eventBus, + concurrencyManager, + worktreeResolver, + settingsService, + createRunAgentFn(), + (context) => pipelineOrchestrator.executePipeline(context), + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + async (_feature) => { + // getPlanningPromptPrefixFn - planning prompts handled by AutoModeService + return ''; + }, + (pPath, featureId, summary) => + featureStateManager.saveFeatureSummary(pPath, featureId, summary), + async () => { + /* recordLearnings - stub */ + }, + (pPath, featureId) => getFacade().contextExists(featureId), + (pPath, featureId, useWorktrees, _calledInternally) => + getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), + (errorInfo) => { + // Track failure against ALL active worktrees for this project. + // The ExecutionService callbacks don't receive branchName, so we + // iterate all active worktrees. Uses a for-of loop (not .some()) to + // ensure every worktree's failure counter is incremented. + let shouldPause = false; + forEachProjectWorktree((branchName) => { + if ( + autoLoopCoordinator.trackFailureAndCheckPauseForProject( + projectPath, + branchName, + errorInfo + ) + ) { + shouldPause = true; + } + }); + return shouldPause; + }, + (errorInfo) => { + forEachProjectWorktree((branchName) => + autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo) + ); + }, + () => { + // Record success to clear failure tracking. This prevents failures + // from accumulating over time and incorrectly pausing auto mode. + forEachProjectWorktree((branchName) => + autoLoopCoordinator.recordSuccessForProject(projectPath, branchName) + ); + }, + (_pPath) => getFacade().saveExecutionState(), + loadContextFiles + ); + + // RecoveryService + const recoveryService = new RecoveryService( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) => + getFacade().executeFeature(featureId, useWorktrees, isAutoMode, providedWorktreePath, opts), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + (pPath, featureId, status) => + pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status), + (pPath, feature, useWorktrees, pipelineInfo) => + pipelineOrchestrator.resumePipeline(pPath, feature, useWorktrees, pipelineInfo), + (featureId) => concurrencyManager.isRunning(featureId), + (opts) => concurrencyManager.acquire(opts), + (featureId) => concurrencyManager.release(featureId) + ); + + // Create the facade instance + facadeInstance = new AutoModeServiceFacade( + projectPath, + events, + eventBus, + concurrencyManager, + worktreeResolver, + featureStateManager, + featureLoader, + planApprovalService, + autoLoopCoordinator, + executionService, + recoveryService, + pipelineOrchestrator, + settingsService + ); + + return facadeInstance; + } + + // =========================================================================== + // AUTO LOOP CONTROL (4 methods) + // =========================================================================== + + /** + * Start the auto mode loop for this project/worktree + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features + */ + async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise { + try { + return await this.autoLoopCoordinator.startAutoLoopForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } catch (error) { + this.handleFacadeError(error, 'startAutoLoop'); + throw error; + } + } + + /** + * Stop the auto mode loop for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + async stopAutoLoop(branchName: string | null = null): Promise { + try { + return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); + } catch (error) { + this.handleFacadeError(error, 'stopAutoLoop'); + throw error; + } + } + + /** + * Check if auto mode is running for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + isAutoLoopRunning(branchName: string | null = null): boolean { + return this.autoLoopCoordinator.isAutoLoopRunningForProject(this.projectPath, branchName); + } + + /** + * Get auto loop config for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + getAutoLoopConfig(branchName: string | null = null): AutoModeConfig | null { + return this.autoLoopCoordinator.getAutoLoopConfigForProject(this.projectPath, branchName); + } + + // =========================================================================== + // FEATURE EXECUTION (6 methods) + // =========================================================================== + + /** + * Execute a single feature + * @param featureId - The feature ID to execute + * @param useWorktrees - Whether to use worktrees for isolation + * @param isAutoMode - Whether this is running in auto mode + * @param providedWorktreePath - Optional pre-resolved worktree path + * @param options - Additional execution options + */ + async executeFeature( + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { + continuationPrompt?: string; + _calledInternally?: boolean; + } + ): Promise { + try { + return await this.executionService.executeFeature( + this.projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } catch (error) { + this.handleFacadeError(error, 'executeFeature', featureId); + throw error; + } + } + + /** + * Stop a specific feature + * @param featureId - ID of the feature to stop + */ + async stopFeature(featureId: string): Promise { + try { + // Cancel any pending plan approval for this feature + this.cancelPlanApproval(featureId); + return await this.executionService.stopFeature(featureId); + } catch (error) { + this.handleFacadeError(error, 'stopFeature', featureId); + throw error; + } + } + + /** + * Resume a feature (continues from saved context or starts fresh) + * @param featureId - ID of the feature to resume + * @param useWorktrees - Whether to use git worktrees + * @param _calledInternally - Internal flag for nested calls + */ + async resumeFeature( + featureId: string, + useWorktrees = false, + _calledInternally = false + ): Promise { + // Note: ExecutionService.executeFeature catches its own errors internally and + // does NOT re-throw them (it emits auto_mode_error and returns normally). + // Therefore, errors that reach this catch block are pre-execution failures + // (e.g., feature not found, context read error) that ExecutionService never + // handled — so calling handleFacadeError here does NOT produce duplicate events. + try { + return await this.recoveryService.resumeFeature( + this.projectPath, + featureId, + useWorktrees, + _calledInternally + ); + } catch (error) { + this.handleFacadeError(error, 'resumeFeature', featureId); + throw error; + } + } + + /** + * Follow up on a feature with additional instructions + * @param featureId - The feature ID + * @param prompt - Follow-up prompt + * @param imagePaths - Optional image paths + * @param useWorktrees - Whether to use worktrees + */ + async followUpFeature( + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + validateWorkingDirectory(this.projectPath); + + try { + // Load feature to build the prompt context + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + + // Read previous agent output as context + const featureDir = getFeatureDir(this.projectPath, featureId); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile( + path.join(featureDir, 'agent-output.md'), + 'utf-8' + )) as string; + } catch { + // No previous context available - that's OK + } + + // Build the feature prompt section + const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; + + // Get the follow-up prompt template and build the continuation prompt + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + let continuationPrompt = prompts.autoMode.followUpPromptTemplate; + continuationPrompt = continuationPrompt + .replace(/\{\{featurePrompt\}\}/g, featurePrompt) + .replace(/\{\{previousContext\}\}/g, previousContext) + .replace(/\{\{followUpInstructions\}\}/g, prompt); + + // Store image paths on the feature so executeFeature can pick them up + if (imagePaths && imagePaths.length > 0) { + feature.imagePaths = imagePaths.map((p) => ({ + path: p, + filename: p.split('/').pop() || p, + mimeType: 'image/*', + })); + await this.featureStateManager.updateFeatureStatus( + this.projectPath, + featureId, + feature.status || 'in_progress' + ); + } + + // Delegate to executeFeature with the built continuation prompt + await this.executeFeature(featureId, useWorktrees, false, undefined, { + continuationPrompt, + }); + } catch (error) { + const errorInfo = classifyError(error); + if (!errorInfo.isAbort) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: undefined, + branchName: null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + throw error; + } + } + + /** + * Verify a feature's implementation + * @param featureId - The feature ID to verify + */ + async verifyFeature(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + let workDir = this.projectPath; + + // Use worktreeResolver to find worktree path (consistent with commitFeature) + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + try { + await secureFs.access(resolved); + workDir = resolved; + } catch { + // Fall back to project path + } + } + } + + const verificationChecks = [ + { cmd: 'npm run lint', name: 'Lint' }, + { cmd: 'npm run typecheck', name: 'Type check' }, + { cmd: 'npm test', name: 'Tests' }, + { cmd: 'npm run build', name: 'Build' }, + ]; + + let allPassed = true; + const results: Array<{ check: string; passed: boolean; output?: string }> = []; + + for (const check of verificationChecks) { + try { + const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000 }); + results.push({ check: check.name, passed: true, output: stdout || stderr }); + } catch (error) { + allPassed = false; + results.push({ check: check.name, passed: false, output: (error as Error).message }); + break; + } + } + + const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForVerify?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: allPassed, + message: allPassed + ? 'All verification checks passed' + : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, + projectPath: this.projectPath, + }); + } + + return allPassed; + } + + /** + * Commit feature changes + * @param featureId - The feature ID to commit + * @param providedWorktreePath - Optional worktree path + */ + async commitFeature(featureId: string, providedWorktreePath?: string): Promise { + let workDir = this.projectPath; + + if (providedWorktreePath) { + try { + await secureFs.access(providedWorktreePath); + workDir = providedWorktreePath; + } catch { + // Use project path + } + } else { + // Use worktreeResolver instead of manual .worktrees lookup + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + workDir = resolved; + } + } + } + + try { + const status = await execGitCommand(['status', '--porcelain'], workDir); + if (!status.trim()) { + return null; + } + + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const title = + feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; + const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; + + await execGitCommand(['add', '-A'], workDir); + await execGitCommand(['commit', '-m', commitMessage], workDir); + const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir); + + const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForCommit?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: true, + message: `Changes committed: ${hash.trim().substring(0, 8)}`, + projectPath: this.projectPath, + }); + } + + return hash.trim(); + } catch (error) { + logger.error(`Commit failed for ${featureId}:`, error); + return null; + } + } + + // =========================================================================== + // STATUS AND QUERIES (7 methods) + // =========================================================================== + + /** + * Get current status (global across all projects) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get status for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + async getStatusForProject(branchName: string | null = null): Promise { + const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject( + this.projectPath, + branchName + ); + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + // Use branchName-normalized filter so features with branchName "main" + // are correctly matched when querying for the main worktree (null) + const runningFeatures = await this.concurrencyManager.getRunningFeaturesForWorktree( + this.projectPath, + branchName + ); + + return { + isAutoLoopRunning, + runningFeatures, + runningCount: runningFeatures.length, + maxConcurrency: config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + return this.autoLoopCoordinator.getActiveProjects(); + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.autoLoopCoordinator.getActiveWorktrees(); + } + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + const agents = await Promise.all( + this.concurrencyManager.getAllRunning().map(async (rf) => { + let title: string | undefined; + let description: string | undefined; + let branchName: string | undefined; + + try { + const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); + if (feature) { + title = feature.title; + description = feature.description; + branchName = feature.branchName ?? undefined; + } + } catch { + // Silently ignore + } + + return { + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + model: rf.model, + provider: rf.provider, + title, + description, + branchName, + }; + }) + ); + return agents; + } + + /** + * Check if there's capacity to start a feature on a worktree + * @param featureId - The feature ID to check capacity for + */ + async checkWorktreeCapacity(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const rawBranchName = feature?.branchName ?? null; + // Normalize primary branch to null (works for main, master, or any default branch) + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + const branchName = rawBranchName === primaryBranch ? null : rawBranchName; + + const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency( + this.projectPath, + branchName + ); + const currentAgents = await this.concurrencyManager.getRunningCountForWorktree( + this.projectPath, + branchName + ); + + return { + hasCapacity: currentAgents < maxAgents, + currentAgents, + maxAgents, + branchName, + }; + } + + /** + * Check if context exists for a feature + * @param featureId - The feature ID + */ + async contextExists(featureId: string): Promise { + return this.recoveryService.contextExists(this.projectPath, featureId); + } + + // =========================================================================== + // PLAN APPROVAL (4 methods) + // =========================================================================== + + /** + * Resolve a pending plan approval + * @param featureId - The feature ID + * @param approved - Whether the plan was approved + * @param editedPlan - Optional edited plan content + * @param feedback - Optional feedback + */ + async resolvePlanApproval( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const result = await this.planApprovalService.resolveApproval(featureId, approved, { + editedPlan, + feedback, + projectPath: this.projectPath, + }); + + // Handle recovery case + if (result.success && result.needsRecovery) { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (feature) { + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + const planContent = editedPlan || feature.planSpec?.content || ''; + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || ''); + continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); + + // Start execution async + this.executeFeature(featureId, true, false, undefined, { continuationPrompt }).catch( + (error) => { + logger.error(`Recovery execution failed for feature ${featureId}:`, error); + } + ); + } + } + + return { success: result.success, error: result.error }; + } + + /** + * Wait for plan approval + * @param featureId - The feature ID + */ + waitForPlanApproval( + featureId: string + ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { + return this.planApprovalService.waitForApproval(featureId, this.projectPath); + } + + /** + * Check if a feature has a pending plan approval + * @param featureId - The feature ID + */ + hasPendingApproval(featureId: string): boolean { + return this.planApprovalService.hasPendingApproval(featureId, this.projectPath); + } + + /** + * Cancel a pending plan approval + * @param featureId - The feature ID + */ + cancelPlanApproval(featureId: string): void { + this.planApprovalService.cancelApproval(featureId, this.projectPath); + } + + // =========================================================================== + // ANALYSIS AND RECOVERY (3 methods) + // =========================================================================== + + /** + * Analyze project to gather context + * + * NOTE: This method requires complex provider integration that is only available + * in AutoModeService. The facade exposes the method signature for API compatibility, + * but routes should use AutoModeService.analyzeProject() until migration is complete. + */ + async analyzeProject(): Promise { + // analyzeProject requires provider.execute which is complex to wire up + // For now, throw to indicate routes should use AutoModeService + throw new Error( + 'analyzeProject not fully implemented in facade - use AutoModeService.analyzeProject instead' + ); + } + + /** + * Resume interrupted features after server restart + */ + async resumeInterruptedFeatures(): Promise { + return this.recoveryService.resumeInterruptedFeatures(this.projectPath); + } + + /** + * Detect orphaned features (features with missing branches) + */ + async detectOrphanedFeatures(): Promise { + const orphanedFeatures: OrphanedFeatureInfo[] = []; + + try { + const allFeatures = await this.featureLoader.getAll(this.projectPath); + const featuresWithBranches = allFeatures.filter( + (f) => f.branchName && f.branchName.trim() !== '' + ); + + if (featuresWithBranches.length === 0) { + return orphanedFeatures; + } + + // Get existing branches (using safe array-based command) + const stdout = await execGitCommand( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], + this.projectPath + ); + const existingBranches = new Set( + stdout + .trim() + .split('\n') + .map((b) => b.trim()) + .filter(Boolean) + ); + + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + + for (const feature of featuresWithBranches) { + const branchName = feature.branchName!; + if (primaryBranch && branchName === primaryBranch) { + continue; + } + if (!existingBranches.has(branchName)) { + orphanedFeatures.push({ feature, missingBranch: branchName }); + } + } + + return orphanedFeatures; + } catch (error) { + logger.error('[detectOrphanedFeatures] Error:', error); + return orphanedFeatures; + } + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + const allRunning = this.concurrencyManager.getAllRunning(); + + for (const rf of allRunning) { + await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); + } + + if (allRunning.length > 0) { + logger.info( + `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` + ); + } + } + + // =========================================================================== + // INTERNAL HELPERS + // =========================================================================== + + /** + * Save execution state for recovery. + * + * Uses the active auto-loop config for each worktree so that the persisted + * state reflects the real branch and maxConcurrency values rather than the + * hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY). + */ + private async saveExecutionState(): Promise { + const projectWorktrees = this.autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === this.projectPath); + + if (projectWorktrees.length === 0) { + // No active auto loops — save with defaults as a best-effort fallback. + return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); + } + + // Save state for every active worktree using its real config values. + for (const { branchName } of projectWorktrees) { + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; + await this.saveExecutionStateForProject(branchName, maxConcurrency); + } + } + + /** + * Save execution state for a specific worktree + */ + private async saveExecutionStateForProject( + branchName: string | null, + maxConcurrency: number + ): Promise { + return this.recoveryService.saveExecutionStateForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } + + /** + * Clear execution state + */ + private async clearExecutionState(branchName: string | null = null): Promise { + return this.recoveryService.clearExecutionState(this.projectPath, branchName); + } +} diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts new file mode 100644 index 00000000..478f48b3 --- /dev/null +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -0,0 +1,224 @@ +/** + * GlobalAutoModeService - Global operations for auto-mode that span across all projects + * + * This service manages global state and operations that are not project-specific: + * - Overall status (all running features across all projects) + * - Active auto loop projects and worktrees + * - Graceful shutdown (mark all features as interrupted) + * + * Per-project operations should use AutoModeServiceFacade instead. + */ + +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../lib/events.js'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { SharedServices, AutoModeStatus, RunningAgentInfo } from './types.js'; + +const logger = createLogger('GlobalAutoModeService'); + +/** + * GlobalAutoModeService provides global operations for auto-mode. + * + * Created once at server startup, shared across all facades. + */ +export class GlobalAutoModeService { + private readonly eventBus: TypedEventBus; + private readonly concurrencyManager: ConcurrencyManager; + private readonly autoLoopCoordinator: AutoLoopCoordinator; + private readonly worktreeResolver: WorktreeResolver; + private readonly featureStateManager: FeatureStateManager; + private readonly featureLoader: FeatureLoader; + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader = new FeatureLoader() + ) { + this.featureLoader = featureLoader; + this.eventBus = new TypedEventBus(events); + this.worktreeResolver = new WorktreeResolver(); + this.concurrencyManager = new ConcurrencyManager((p) => + this.worktreeResolver.getCurrentBranch(p) + ); + this.featureStateManager = new FeatureStateManager(events, featureLoader); + + // Create AutoLoopCoordinator with callbacks + // IMPORTANT: This coordinator is for MONITORING ONLY (getActiveProjects, getActiveWorktrees). + // Facades MUST create their own AutoLoopCoordinator for actual execution. + // The executeFeatureFn here is a safety guard - it should never be called. + this.autoLoopCoordinator = new AutoLoopCoordinator( + this.eventBus, + this.concurrencyManager, + settingsService, + // executeFeatureFn - throws because facades must use their own coordinator for execution + async () => { + throw new Error( + 'executeFeatureFn not available in GlobalAutoModeService. ' + + 'Facades must create their own AutoLoopCoordinator for execution.' + ); + }, + // getBacklogFeaturesFn + async (pPath, branchName) => { + const features = await featureLoader.getAll(pPath); + // For main worktree (branchName === null), resolve the actual primary branch name + // so features with branchName matching the primary branch are included + let primaryBranch: string | null = null; + if (branchName === null) { + primaryBranch = await this.worktreeResolver.getCurrentBranch(pPath); + } + return features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || (primaryBranch && f.branchName === primaryBranch) + : f.branchName === branchName) + ); + }, + // saveExecutionStateFn - placeholder + async () => {}, + // clearExecutionStateFn - placeholder + async () => {}, + // resetStuckFeaturesFn + (pPath) => this.featureStateManager.resetStuckFeatures(pPath), + // isFeatureDoneFn + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + // isFeatureRunningFn + (featureId) => this.concurrencyManager.isRunning(featureId) + ); + } + + /** + * Get the shared services for use by facades. + * This allows facades to share state with the global service. + */ + getSharedServices(): SharedServices { + return { + eventBus: this.eventBus, + concurrencyManager: this.concurrencyManager, + autoLoopCoordinator: this.autoLoopCoordinator, + worktreeResolver: this.worktreeResolver, + }; + } + + // =========================================================================== + // GLOBAL STATUS (3 methods) + // =========================================================================== + + /** + * Get global status (all projects combined) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + return this.autoLoopCoordinator.getActiveProjects(); + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.autoLoopCoordinator.getActiveWorktrees(); + } + + // =========================================================================== + // RUNNING AGENTS (1 method) + // =========================================================================== + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + const agents = await Promise.all( + this.concurrencyManager.getAllRunning().map(async (rf) => { + let title: string | undefined; + let description: string | undefined; + let branchName: string | undefined; + + try { + const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); + if (feature) { + title = feature.title; + description = feature.description; + branchName = feature.branchName ?? undefined; + } + } catch { + // Silently ignore + } + + return { + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + model: rf.model, + provider: rf.provider, + title, + description, + branchName, + }; + }) + ); + return agents; + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted. + * Called during graceful shutdown. + * + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + const allRunning = this.concurrencyManager.getAllRunning(); + + for (const rf of allRunning) { + await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); + } + + if (allRunning.length > 0) { + logger.info( + `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` + ); + } + } + + /** + * Reconcile all feature states for a project on server startup. + * + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to a resting state and emits events so the UI reflects corrected states. + * + * This should be called during server initialization to handle: + * - Clean shutdown: features already marked as interrupted + * - Forced kill / crash: features left in in_progress or pipeline_* states + * + * @param projectPath - The project path to reconcile + * @returns The number of features that were reconciled + */ + async reconcileFeatureStates(projectPath: string): Promise { + return this.featureStateManager.reconcileAllFeatureStates(projectPath); + } +} diff --git a/apps/server/src/services/auto-mode/index.ts b/apps/server/src/services/auto-mode/index.ts new file mode 100644 index 00000000..40e0ee84 --- /dev/null +++ b/apps/server/src/services/auto-mode/index.ts @@ -0,0 +1,77 @@ +/** + * Auto Mode Service Module + * + * Entry point for auto-mode functionality. Exports: + * - GlobalAutoModeService: Global operations that span all projects + * - AutoModeServiceFacade: Per-project facade for auto-mode operations + * - createAutoModeFacade: Convenience factory function + * - Types for route consumption + */ + +// Main exports +export { GlobalAutoModeService } from './global-service.js'; +export { AutoModeServiceFacade } from './facade.js'; +export { AutoModeServiceCompat } from './compat.js'; + +// Convenience factory function +import { AutoModeServiceFacade } from './facade.js'; +import type { FacadeOptions } from './types.js'; + +/** + * Create an AutoModeServiceFacade instance for a specific project. + * + * This is a convenience wrapper around AutoModeServiceFacade.create(). + * + * @param projectPath - The project path this facade operates on + * @param options - Configuration options including events, settingsService, featureLoader + * @returns A new AutoModeServiceFacade instance + * + * @example + * ```typescript + * import { createAutoModeFacade } from './services/auto-mode'; + * + * const facade = createAutoModeFacade('/path/to/project', { + * events: eventEmitter, + * settingsService, + * }); + * + * // Start auto mode + * await facade.startAutoLoop(null, 3); + * + * // Check status + * const status = facade.getStatusForProject(); + * ``` + */ +export function createAutoModeFacade( + projectPath: string, + options: FacadeOptions +): AutoModeServiceFacade { + return AutoModeServiceFacade.create(projectPath, options); +} + +// Type exports from types.ts +export type { + FacadeOptions, + SharedServices, + AutoModeStatus, + ProjectAutoModeStatus, + WorktreeCapacityInfo, + RunningAgentInfo, + OrphanedFeatureInfo, + FacadeError, + GlobalAutoModeOperations, +} from './types.js'; + +// Re-export types from extracted services for route convenience +export type { + AutoModeConfig, + ProjectAutoLoopState, + RunningFeature, + AcquireParams, + WorktreeInfo, + PipelineContext, + PipelineStatusInfo, + PlanApprovalResult, + ResolveApprovalResult, + ExecutionState, +} from './types.js'; diff --git a/apps/server/src/services/auto-mode/types.ts b/apps/server/src/services/auto-mode/types.ts new file mode 100644 index 00000000..fc82cb13 --- /dev/null +++ b/apps/server/src/services/auto-mode/types.ts @@ -0,0 +1,148 @@ +/** + * Facade Types - Type definitions for AutoModeServiceFacade + * + * Contains: + * - FacadeOptions interface for factory configuration + * - Re-exports of types from extracted services that routes might need + * - Additional types for facade method signatures + */ + +import type { EventEmitter } from '../../lib/events.js'; +import type { Feature, ModelProvider } from '@automaker/types'; +import type { SettingsService } from '../settings-service.js'; +import type { FeatureLoader } from '../feature-loader.js'; +import type { ConcurrencyManager } from '../concurrency-manager.js'; +import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import type { WorktreeResolver } from '../worktree-resolver.js'; +import type { TypedEventBus } from '../typed-event-bus.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; + +// Re-export types from extracted services for route consumption +export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js'; + +export type { RunningFeature, AcquireParams } from '../concurrency-manager.js'; + +export type { WorktreeInfo } from '../worktree-resolver.js'; + +export type { PipelineContext, PipelineStatusInfo } from '../pipeline-orchestrator.js'; + +export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval-service.js'; + +export type { ExecutionState } from '../recovery-service.js'; + +/** + * Shared services that can be passed to facades to enable state sharing + */ +export interface SharedServices { + /** TypedEventBus for typed event emission */ + eventBus: TypedEventBus; + /** ConcurrencyManager for tracking running features across all projects */ + concurrencyManager: ConcurrencyManager; + /** AutoLoopCoordinator for managing auto loop state across all projects */ + autoLoopCoordinator: AutoLoopCoordinator; + /** WorktreeResolver for git worktree operations */ + worktreeResolver: WorktreeResolver; +} + +/** + * Options for creating an AutoModeServiceFacade instance + */ +export interface FacadeOptions { + /** EventEmitter for broadcasting events to clients */ + events: EventEmitter; + /** SettingsService for reading project/global settings (optional) */ + settingsService?: SettingsService | null; + /** FeatureLoader for loading feature data (optional, defaults to new FeatureLoader()) */ + featureLoader?: FeatureLoader; + /** Shared services for state sharing across facades (optional) */ + sharedServices?: SharedServices; + /** ClaudeUsageService for checking usage limits before picking up features (optional) */ + claudeUsageService?: ClaudeUsageService | null; +} + +/** + * Status returned by getStatus() + */ +export interface AutoModeStatus { + isRunning: boolean; + runningFeatures: string[]; + runningCount: number; +} + +/** + * Status returned by getStatusForProject() + */ +export interface ProjectAutoModeStatus { + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + branchName: string | null; +} + +/** + * Capacity info returned by checkWorktreeCapacity() + */ +export interface WorktreeCapacityInfo { + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; +} + +/** + * Running agent info returned by getRunningAgents() + */ +export interface RunningAgentInfo { + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; + model?: string; + provider?: ModelProvider; + title?: string; + description?: string; + branchName?: string; +} + +/** + * Orphaned feature info returned by detectOrphanedFeatures() + */ +export interface OrphanedFeatureInfo { + feature: Feature; + missingBranch: string; +} + +/** + * Structured error object returned/emitted by facade methods. + * Provides consistent error information for callers and UI consumers. + */ +export interface FacadeError { + /** The facade method where the error originated */ + method: string; + /** Classified error type from the error handler */ + errorType: import('@automaker/types').ErrorType; + /** Human-readable error message */ + message: string; + /** Feature ID if the error is associated with a specific feature */ + featureId?: string; + /** Project path where the error occurred */ + projectPath: string; +} + +/** + * Interface describing global auto-mode operations (not project-specific). + * Used by routes that need global state access. + */ +export interface GlobalAutoModeOperations { + /** Get global status (all projects combined) */ + getStatus(): AutoModeStatus; + /** Get all active auto loop projects (unique project paths) */ + getActiveAutoLoopProjects(): string[]; + /** Get all active auto loop worktrees */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }>; + /** Get detailed info about all running agents */ + getRunningAgents(): Promise; + /** Mark all running features as interrupted (for graceful shutdown) */ + markAllRunningFeaturesInterrupted(reason?: string): Promise; +} diff --git a/apps/server/src/services/branch-commit-log-service.ts b/apps/server/src/services/branch-commit-log-service.ts new file mode 100644 index 00000000..9666f98c --- /dev/null +++ b/apps/server/src/services/branch-commit-log-service.ts @@ -0,0 +1,172 @@ +/** + * Service for fetching branch commit log data. + * + * Extracts the heavy Git command execution and parsing logic from the + * branch-commit-log route handler so the handler only validates input, + * invokes this service, streams lifecycle events, and sends the response. + */ + +import { execGitCommand } from '../lib/git.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BranchCommit { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + subject: string; + body: string; + files: string[]; +} + +export interface BranchCommitLogResult { + branch: string; + commits: BranchCommit[]; + total: number; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Fetch the commit log for a specific branch (or HEAD). + * + * Runs a single `git log --name-only` invocation (plus `git rev-parse` + * when branchName is omitted) inside the given worktree path and + * returns a structured result. + * + * @param worktreePath - Absolute path to the worktree / repository + * @param branchName - Branch to query (omit or pass undefined for HEAD) + * @param limit - Maximum number of commits to return (clamped 1-100) + */ +export async function getBranchCommitLog( + worktreePath: string, + branchName: string | undefined, + limit: number +): Promise { + // Clamp limit to a reasonable range + const parsedLimit = Number(limit); + const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100); + + // Use the specified branch or default to HEAD + const targetRef = branchName || 'HEAD'; + + // Fetch commit metadata AND file lists in a single git call. + // Uses custom record separators so we can parse both metadata and + // --name-only output from one invocation, eliminating the previous + // N+1 pattern that spawned a separate `git diff-tree` per commit. + // + // -m causes merge commits to be diffed against each parent so all + // files touched by the merge are listed (without -m, --name-only + // produces no file output for merge commits because they have 2+ parents). + // This means merge commits appear multiple times in the output (once per + // parent), so we deduplicate by hash below and merge their file lists. + // We over-fetch (2× the limit) to compensate for -m duplicating merge + // commit entries, then trim the result to the requested limit. + // Use ASCII control characters as record separators – these cannot appear in + // git commit messages, so these delimiters are safe regardless of commit + // body content. %x00 and %x01 in git's format string emit literal NUL / + // SOH bytes respectively. + // + // COMMIT_SEP (\x00) – marks the start of each commit record. + // META_END (\x01) – separates commit metadata from the --name-only file list. + // + // Full per-commit layout emitted by git: + // \x00\n\n\n...\n\n\x01 + const COMMIT_SEP = '\x00'; + const META_END = '\x01'; + const fetchLimit = commitLimit * 2; + + const logOutput = await execGitCommand( + [ + 'log', + targetRef, + `--max-count=${fetchLimit}`, + '-m', + '--name-only', + `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`, + ], + worktreePath + ); + + // Split output into per-commit blocks and drop the empty first chunk + // (the output starts with a NUL commit separator). + const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim()); + + // Use a Map to deduplicate merge commit entries (which appear once per + // parent when -m is used) while preserving insertion order. + const commitMap = new Map(); + + for (const block of commitBlocks) { + const metaEndIdx = block.indexOf(META_END); + if (metaEndIdx === -1) continue; // malformed block, skip + + // --- Parse metadata (everything before the META_END delimiter) --- + const metaRaw = block.substring(0, metaEndIdx); + const metaLines = metaRaw.split('\n'); + + // The first line may be empty (newline right after COMMIT_SEP), skip it + const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== ''); + if (nonEmptyStart === -1) continue; + + const fields = metaLines.slice(nonEmptyStart); + if (fields.length < 6) continue; // need at least hash..subject + + const hash = fields[0].trim(); + if (!hash) continue; // defensive: skip if hash is empty + const shortHash = fields[1]?.trim() ?? ''; + const author = fields[2]?.trim() ?? ''; + const authorEmail = fields[3]?.trim() ?? ''; + const date = fields[4]?.trim() ?? ''; + const subject = fields[5]?.trim() ?? ''; + const body = fields.slice(6).join('\n').trim(); + + // --- Parse file list (everything after the META_END delimiter) --- + const filesRaw = block.substring(metaEndIdx + META_END.length); + const blockFiles = filesRaw + .trim() + .split('\n') + .filter((f) => f.trim()); + + // Merge file lists for duplicate entries (merge commits with -m) + const existing = commitMap.get(hash); + if (existing) { + // Add new files to the existing entry's file set + const fileSet = new Set(existing.files); + for (const f of blockFiles) fileSet.add(f); + existing.files = [...fileSet]; + } else { + commitMap.set(hash, { + hash, + shortHash, + author, + authorEmail, + date, + subject, + body, + files: [...new Set(blockFiles)], + }); + } + } + + // Trim to the requested limit (we over-fetched to account for -m duplicates) + const commits = [...commitMap.values()].slice(0, commitLimit); + + // If branchName wasn't specified, get current branch for display + let displayBranch = branchName; + if (!displayBranch) { + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + displayBranch = branchOutput.trim(); + } + + return { + branch: displayBranch, + commits, + total: commits.length, + }; +} diff --git a/apps/server/src/services/branch-sync-service.ts b/apps/server/src/services/branch-sync-service.ts new file mode 100644 index 00000000..6b9d48c3 --- /dev/null +++ b/apps/server/src/services/branch-sync-service.ts @@ -0,0 +1,426 @@ +/** + * branch-sync-service - Sync a local base branch with its remote tracking branch + * + * Provides logic to detect remote tracking branches, check whether a branch + * is checked out in any worktree, and fast-forward a local branch to match + * its remote counterpart. Extracted from the worktree create route so + * the git logic is decoupled from HTTP request/response handling. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; + +const logger = createLogger('BranchSyncService'); + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Result of attempting to sync a base branch with its remote. + */ +export interface BaseBranchSyncResult { + /** Whether the sync was attempted */ + attempted: boolean; + /** Whether the sync succeeded */ + synced: boolean; + /** Whether the ref was resolved (but not synced, e.g. remote ref, tag, or commit hash) */ + resolved?: boolean; + /** The remote that was synced from (e.g. 'origin') */ + remote?: string; + /** The commit hash the base branch points to after sync */ + commitHash?: string; + /** Human-readable message about the sync result */ + message?: string; + /** Whether the branch had diverged (local commits ahead of remote) */ + diverged?: boolean; + /** Whether the user can proceed with a stale local copy */ + canProceedWithStale?: boolean; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Detect the remote tracking branch for a given local branch. + * + * @param projectPath - Path to the git repository + * @param branchName - Local branch name to check (e.g. 'main') + * @returns Object with remote name and remote branch, or null if no tracking branch + */ +export async function getTrackingBranch( + projectPath: string, + branchName: string +): Promise<{ remote: string; remoteBranch: string } | null> { + try { + // git rev-parse --abbrev-ref @{upstream} returns e.g. "origin/main" + const upstream = await execGitCommand( + ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], + projectPath + ); + const trimmed = upstream.trim(); + if (!trimmed) return null; + + // First, attempt to determine the remote name explicitly via git config + // so that remotes whose names contain slashes are handled correctly. + let remote: string | null = null; + try { + const configRemote = await execGitCommand( + ['config', '--get', `branch.${branchName}.remote`], + projectPath + ); + const configRemoteTrimmed = configRemote.trim(); + if (configRemoteTrimmed) { + remote = configRemoteTrimmed; + } + } catch { + // git config lookup failed — will fall back to string splitting below + } + + if (remote) { + // Strip the known remote prefix (plus the separating '/') to get the remote branch. + // The upstream string is expected to be "/". + const prefix = `${remote}/`; + if (trimmed.startsWith(prefix)) { + return { + remote, + remoteBranch: trimmed.substring(prefix.length), + }; + } + // Upstream doesn't start with the expected prefix — fall through to split + } + + // Fall back: split on the FIRST slash, which favors the common case of + // single-name remotes with slash-containing branch names (e.g. + // "origin/feature/foo" → remote="origin", remoteBranch="feature/foo"). + // Remotes with slashes in their names are uncommon and are already handled + // by the git-config lookup above; this fallback only runs when that lookup + // fails, so optimizing for single-name remotes is the safer default. + const slashIndex = trimmed.indexOf('/'); + if (slashIndex > 0) { + return { + remote: trimmed.substring(0, slashIndex), + remoteBranch: trimmed.substring(slashIndex + 1), + }; + } + return null; + } catch { + // No upstream tracking branch configured + return null; + } +} + +/** + * Check whether a branch is checked out in ANY worktree (main or linked). + * Uses `git worktree list --porcelain` to enumerate all worktrees and + * checks if any of them has the given branch as their HEAD. + * + * Returns the absolute path of the worktree where the branch is checked out, + * or null if the branch is not checked out anywhere. Callers can use the + * returned path to run commands (e.g. `git merge`) inside the correct worktree. + * + * This prevents using `git update-ref` on a branch that is checked out in + * a linked worktree, which would desync that worktree's HEAD. + */ +export async function isBranchCheckedOut( + projectPath: string, + branchName: string +): Promise { + try { + const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath); + const lines = stdout.split('\n'); + let currentWorktreePath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentWorktreePath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '') { + // End of a worktree entry — check for match, then reset for the next + if (currentBranch === branchName && currentWorktreePath) { + return currentWorktreePath; + } + currentWorktreePath = null; + currentBranch = null; + } + } + + // Check the last entry (if output doesn't end with a blank line) + if (currentBranch === branchName && currentWorktreePath) { + return currentWorktreePath; + } + + return null; + } catch { + return null; + } +} + +/** + * Build a BaseBranchSyncResult for cases where we proceed with a stale local copy. + * Extracts the repeated pattern of getting the short commit hash with a fallback. + */ +export async function buildStaleResult( + projectPath: string, + branchName: string, + remote: string | undefined, + message: string, + extra?: Partial +): Promise { + let commitHash: string | undefined; + try { + const hash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + commitHash = hash.trim(); + } catch { + /* ignore — commit hash is non-critical */ + } + return { + attempted: true, + synced: false, + remote, + commitHash, + message, + canProceedWithStale: true, + ...extra, + }; +} + +// ============================================================================ +// Main Sync Function +// ============================================================================ + +/** + * Sync a local base branch with its remote tracking branch using fast-forward only. + * + * This function: + * 1. Detects the remote tracking branch for the given local branch + * 2. Fetches latest from that remote (unless skipFetch is true) + * 3. Attempts a fast-forward-only update of the local branch + * 4. If the branch has diverged, reports the divergence and allows proceeding with stale copy + * 5. If no remote tracking branch exists, skips silently + * + * @param projectPath - Path to the git repository + * @param branchName - The local branch name to sync (e.g. 'main') + * @param skipFetch - When true, skip the internal git fetch (caller has already fetched) + * @returns Sync result with status information + */ +export async function syncBaseBranch( + projectPath: string, + branchName: string, + skipFetch = false +): Promise { + // Check if the branch exists as a local branch (under refs/heads/). + // This correctly handles branch names containing slashes (e.g. "feature/abc", + // "fix/issue-123") which are valid local branch names, not remote refs. + let existsLocally = false; + try { + await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], projectPath); + existsLocally = true; + } catch { + existsLocally = false; + } + + if (!existsLocally) { + // Not a local branch — check if it's a valid ref (remote ref, tag, or commit hash). + // No synchronization is performed here; we only resolve the ref to a commit hash. + try { + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + return { + attempted: false, + synced: false, + resolved: true, + commitHash: commitHash.trim(), + message: `Ref '${branchName}' resolved (not a local branch; no sync performed)`, + }; + } catch { + return { + attempted: false, + synced: false, + message: `Ref '${branchName}' not found`, + }; + } + } + + // Detect remote tracking branch + const tracking = await getTrackingBranch(projectPath, branchName); + if (!tracking) { + // No remote tracking branch — skip silently + logger.info(`Branch '${branchName}' has no remote tracking branch, skipping sync`); + try { + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + return { + attempted: false, + synced: false, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' has no remote tracking branch`, + }; + } catch { + return { + attempted: false, + synced: false, + message: `Branch '${branchName}' has no remote tracking branch`, + }; + } + } + + logger.info( + `Syncing base branch '${branchName}' from ${tracking.remote}/${tracking.remoteBranch}` + ); + + // Fetch the specific remote unless the caller has already performed a fetch + // (e.g. via `git fetch --all`) and passed skipFetch=true to avoid redundant work. + if (!skipFetch) { + try { + const fetchController = new AbortController(); + const fetchTimer = setTimeout(() => fetchController.abort(), FETCH_TIMEOUT_MS); + try { + await execGitCommand( + ['fetch', tracking.remote, tracking.remoteBranch, '--quiet'], + projectPath, + undefined, + fetchController + ); + } finally { + clearTimeout(fetchTimer); + } + } catch (fetchErr) { + // Fetch failed — network error, auth error, etc. + // Allow proceeding with stale local copy + const errMsg = getErrorMessage(fetchErr); + logger.warn(`Failed to fetch ${tracking.remote}/${tracking.remoteBranch}: ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Failed to fetch from remote: ${errMsg}. Proceeding with local copy.` + ); + } + } else { + logger.info(`Skipping fetch for '${branchName}' (caller already fetched from remotes)`); + } + + // Check if the local branch is behind, ahead, or diverged from the remote + const remoteRef = `${tracking.remote}/${tracking.remoteBranch}`; + try { + // Count commits ahead and behind + const revListOutput = await execGitCommand( + ['rev-list', '--left-right', '--count', `${branchName}...${remoteRef}`], + projectPath + ); + const parts = revListOutput.trim().split(/\s+/); + const ahead = parseInt(parts[0], 10) || 0; + const behind = parseInt(parts[1], 10) || 0; + + if (ahead === 0 && behind === 0) { + // Already up to date + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Branch '${branchName}' is already up to date with ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' is already up to date`, + }; + } + + if (ahead > 0 && behind > 0) { + // Branch has diverged — cannot fast-forward + logger.warn( + `Branch '${branchName}' has diverged from ${remoteRef} (${ahead} ahead, ${behind} behind)` + ); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Branch '${branchName}' has diverged from ${remoteRef} (${ahead} commit(s) ahead, ${behind} behind). Using local copy to avoid overwriting local commits.`, + { diverged: true } + ); + } + + if (ahead > 0 && behind === 0) { + // Local is ahead — nothing to pull, already has everything from remote plus more + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Branch '${branchName}' is ${ahead} commit(s) ahead of ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' is ${ahead} commit(s) ahead of remote`, + }; + } + + // behind > 0 && ahead === 0 — can fast-forward + logger.info( + `Branch '${branchName}' is ${behind} commit(s) behind ${remoteRef}, fast-forwarding` + ); + + // Determine whether the branch is currently checked out (returns the + // worktree path where it is checked out, or null if not checked out) + const worktreePath = await isBranchCheckedOut(projectPath, branchName); + + if (worktreePath) { + // Branch is checked out in a worktree — use git merge --ff-only + // Run the merge inside the worktree that has the branch checked out + try { + await execGitCommand(['merge', '--ff-only', remoteRef], worktreePath); + } catch (mergeErr) { + const errMsg = getErrorMessage(mergeErr); + logger.warn(`Fast-forward merge failed for '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Fast-forward merge failed: ${errMsg}. Proceeding with local copy.` + ); + } + } else { + // Branch is NOT checked out — use git update-ref to fast-forward without checkout + // This is safe because we already verified the branch is strictly behind (ahead === 0) + try { + const remoteCommit = await execGitCommand(['rev-parse', remoteRef], projectPath); + await execGitCommand( + ['update-ref', `refs/heads/${branchName}`, remoteCommit.trim()], + projectPath + ); + } catch (updateErr) { + const errMsg = getErrorMessage(updateErr); + logger.warn(`update-ref failed for '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Failed to fast-forward branch: ${errMsg}. Proceeding with local copy.` + ); + } + } + + // Successfully fast-forwarded + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Successfully synced '${branchName}' to ${commitHash.trim()} from ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Fast-forwarded '${branchName}' by ${behind} commit(s) from ${remoteRef}`, + }; + } catch (err) { + // Unexpected error during rev-list or merge — proceed with stale + const errMsg = getErrorMessage(err); + logger.warn(`Unexpected error syncing '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Sync failed: ${errMsg}. Proceeding with local copy.` + ); + } +} diff --git a/apps/server/src/services/branch-utils.ts b/apps/server/src/services/branch-utils.ts new file mode 100644 index 00000000..9a618da0 --- /dev/null +++ b/apps/server/src/services/branch-utils.ts @@ -0,0 +1,170 @@ +/** + * branch-utils - Shared git branch helper utilities + * + * Provides common git operations used by both checkout-branch-service and + * worktree-branch-service. Extracted to avoid duplication and ensure + * consistent behaviour across branch-related services. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; + +const logger = createLogger('BranchUtils'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface HasAnyChangesOptions { + /** + * When true, lines that refer to worktree-internal paths (containing + * ".worktrees/" or ending with ".worktrees") are excluded from the count. + * Use this in contexts where worktree directory entries should not be + * considered as real working-tree changes (e.g. worktree-branch-service). + */ + excludeWorktreePaths?: boolean; + /** + * When true (default), untracked files (lines starting with "??") are + * included in the change count. When false, untracked files are ignored so + * that hasAnyChanges() is consistent with stashChanges() called without + * --include-untracked. + */ + includeUntracked?: boolean; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Returns true when a `git status --porcelain` output line refers to a + * worktree-internal path that should be ignored when deciding whether there + * are "real" local changes. + */ +function isExcludedWorktreeLine(line: string): boolean { + return line.includes('.worktrees/') || line.endsWith('.worktrees'); +} + +// ============================================================================ +// Exported Utilities +// ============================================================================ + +/** + * Check if there are any changes that should be stashed. + * + * @param cwd - Working directory of the git repository / worktree + * @param options - Optional flags controlling which lines are counted + * @param options.excludeWorktreePaths - When true, lines matching worktree + * internal paths are excluded so they are not mistaken for real changes + * @param options.includeUntracked - When false, untracked files (lines + * starting with "??") are excluded so this is consistent with a + * stashChanges() call that does not pass --include-untracked. + * Defaults to true. + */ +export async function hasAnyChanges(cwd: string, options?: HasAnyChangesOptions): Promise { + try { + const includeUntracked = options?.includeUntracked ?? true; + const stdout = await execGitCommand(['status', '--porcelain'], cwd); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (options?.excludeWorktreePaths && isExcludedWorktreeLine(line)) return false; + if (!includeUntracked && line.startsWith('??')) return false; + return true; + }); + return lines.length > 0; + } catch (err) { + logger.error('hasAnyChanges: execGitCommand failed — returning false', { + cwd, + error: getErrorMessage(err), + }); + return false; + } +} + +/** + * Stash all local changes (including untracked files if requested). + * Returns true if a stash was created, false if there was nothing to stash. + * Throws on unexpected errors so callers abort rather than proceeding silently. + * + * @param cwd - Working directory of the git repository / worktree + * @param message - Stash message + * @param includeUntracked - When true, passes `--include-untracked` to git stash + */ +export async function stashChanges( + cwd: string, + message: string, + includeUntracked: boolean = true +): Promise { + try { + const args = ['stash', 'push']; + if (includeUntracked) { + args.push('--include-untracked'); + } + args.push('-m', message); + + const stdout = await execGitCommandWithLockRetry(args, cwd); + + // git exits 0 but prints a benign message when there is nothing to stash + const stdoutLower = stdout.toLowerCase(); + if ( + stdoutLower.includes('no local changes to save') || + stdoutLower.includes('nothing to stash') + ) { + logger.debug('stashChanges: nothing to stash', { cwd, message, stdout }); + return false; + } + + return true; + } catch (error) { + const errorMsg = getErrorMessage(error); + + // Unexpected error – log full details and re-throw so the caller aborts + // rather than proceeding with an un-stashed working tree + logger.error('stashChanges: unexpected error during stash', { + cwd, + message, + error: errorMsg, + }); + throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`); + } +} + +/** + * Pop the most recent stash entry. + * Returns an object indicating success and whether there were conflicts. + * + * @param cwd - Working directory of the git repository / worktree + */ +export async function popStash( + cwd: string +): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> { + try { + await execGitCommandWithLockRetry(['stash', 'pop'], cwd); + // If execGitCommandWithLockRetry succeeds (zero exit code), there are no conflicts + return { success: true, hasConflicts: false }; + } catch (error) { + const errorMsg = getErrorMessage(error); + if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) { + return { success: false, hasConflicts: true, error: errorMsg }; + } + return { success: false, hasConflicts: false, error: errorMsg }; + } +} + +/** + * Check if a local branch already exists. + * + * @param cwd - Working directory of the git repository / worktree + * @param branchName - The branch name to look up (without refs/heads/ prefix) + */ +export async function localBranchExists(cwd: string, branchName: string): Promise { + try { + await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd); + return true; + } catch { + return false; + } +} diff --git a/apps/server/src/services/checkout-branch-service.ts b/apps/server/src/services/checkout-branch-service.ts new file mode 100644 index 00000000..922be329 --- /dev/null +++ b/apps/server/src/services/checkout-branch-service.ts @@ -0,0 +1,389 @@ +/** + * CheckoutBranchService - Create and checkout a new branch with stash handling + * + * Handles new branch creation with automatic stash/reapply of local changes. + * If there are uncommitted changes and the caller requests stashing, they are + * stashed before creating the branch and reapplied after. If the stash pop + * results in merge conflicts, returns a special response so the UI can create + * a conflict resolution task. + * + * Follows the same pattern as worktree-branch-service.ts (performSwitchBranch). + * + * The workflow: + * 0. Fetch latest from all remotes (ensures remote refs are up-to-date) + * 1. Validate inputs (branch name, base branch) + * 2. Get current branch name + * 3. Check if target branch already exists + * 4. Optionally stash local changes + * 5. Create and checkout the new branch + * 6. Reapply stashed changes (detect conflicts) + * 7. Handle error recovery (restore stash if checkout fails) + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; +import type { EventEmitter } from '../lib/events.js'; +import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js'; + +const logger = createLogger('CheckoutBranchService'); + +// ============================================================================ +// Local Helpers +// ============================================================================ + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch creation flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. This is called before creating the new branch to + * ensure remote refs are up-to-date when a remote base branch is used. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (controller.signal.aborted) { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs regardless of failure type + } finally { + clearTimeout(timerId); + } +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface CheckoutBranchOptions { + /** When true, stash local changes before checkout and reapply after */ + stashChanges?: boolean; + /** When true, include untracked files in the stash */ + includeUntracked?: boolean; +} + +export interface CheckoutBranchResult { + success: boolean; + error?: string; + result?: { + previousBranch: string; + newBranch: string; + message: string; + hasConflicts?: boolean; + stashedChanges?: boolean; + }; + /** Set when checkout fails and stash pop produced conflicts during recovery */ + stashPopConflicts?: boolean; + /** Human-readable message when stash pop conflicts occur during error recovery */ + stashPopConflictMessage?: string; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Create and checkout a new branch, optionally stashing and restoring local changes. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Name of the new branch to create + * @param baseBranch - Optional base branch to create from (defaults to current HEAD) + * @param options - Stash handling options + * @param events - Optional event emitter for lifecycle events + * @returns CheckoutBranchResult with detailed status information + */ +export async function performCheckoutBranch( + worktreePath: string, + branchName: string, + baseBranch?: string, + options?: CheckoutBranchOptions, + events?: EventEmitter +): Promise { + const shouldStash = options?.stashChanges ?? false; + const includeUntracked = options?.includeUntracked ?? true; + + // Emit start event + events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' }); + + // 0. Fetch latest from all remotes before creating the branch + // This ensures remote refs are up-to-date so that base branch validation + // works correctly for remote branch references (e.g. "origin/main"). + await fetchRemotes(worktreePath); + + // 1. Get current branch + let previousBranch: string; + try { + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + previousBranch = currentBranchOutput.trim(); + } catch (branchError) { + const branchErrorMsg = getErrorMessage(branchError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: branchErrorMsg, + }); + return { + success: false, + error: `Failed to determine current branch: ${branchErrorMsg}`, + }; + } + + // 2. Check if branch already exists + if (await localBranchExists(worktreePath, branchName)) { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Branch '${branchName}' already exists`, + }); + return { + success: false, + error: `Branch '${branchName}' already exists`, + }; + } + + // 3. Validate base branch if provided + if (baseBranch) { + try { + await execGitCommand(['rev-parse', '--verify', baseBranch], worktreePath); + } catch { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Base branch '${baseBranch}' does not exist`, + }); + return { + success: false, + error: `Base branch '${baseBranch}' does not exist`, + }; + } + } + + // 4. Stash local changes if requested and there are changes + let didStash = false; + + if (shouldStash) { + const hadChanges = await hasAnyChanges(worktreePath, { includeUntracked }); + if (hadChanges) { + events?.emit('switch:stash', { + worktreePath, + previousBranch, + targetBranch: branchName, + action: 'push', + }); + + const stashMessage = `Auto-stash before switching to ${branchName}`; + try { + didStash = await stashChanges(worktreePath, stashMessage, includeUntracked); + } catch (stashError) { + const stashErrorMsg = getErrorMessage(stashError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to stash local changes: ${stashErrorMsg}`, + }); + return { + success: false, + error: `Failed to stash local changes before creating branch: ${stashErrorMsg}`, + }; + } + } + } + + try { + // 5. Create and checkout the new branch + events?.emit('switch:checkout', { + worktreePath, + targetBranch: branchName, + isRemote: false, + previousBranch, + }); + + const checkoutArgs = ['checkout', '-b', branchName]; + if (baseBranch) { + checkoutArgs.push(baseBranch); + } + await execGitCommand(checkoutArgs, worktreePath); + + // 6. Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + let stashReapplied = false; + + if (didStash) { + events?.emit('switch:pop', { + worktreePath, + targetBranch: branchName, + action: 'pop', + }); + + // Isolate the pop in its own try/catch so a thrown exception does not + // propagate to the outer catch block, which would attempt a second pop. + try { + const popResult = await popStash(worktreePath); + // Mark didStash false so the outer error-recovery path cannot pop again. + didStash = false; + hasConflicts = popResult.hasConflicts; + if (popResult.hasConflicts) { + conflictMessage = `Created branch '${branchName}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + conflictMessage = `Created branch '${branchName}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } else { + stashReapplied = true; + } + } catch (popError) { + // Pop threw an unexpected exception. Record the error and clear didStash + // so the outer catch does not attempt a second pop. + didStash = false; + conflictMessage = `Created branch '${branchName}' but an error occurred while reapplying stashed changes: ${getErrorMessage(popError)}. Your changes may still be in the stash.`; + events?.emit('switch:pop', { + worktreePath, + targetBranch: branchName, + action: 'pop', + error: getErrorMessage(popError), + }); + } + } + + if (hasConflicts) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + hasConflicts: true, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }; + } else if (didStash && !stashReapplied) { + // Stash pop failed for a non-conflict reason — stash is still present + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + stashPopFailed: true, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: conflictMessage, + hasConflicts: false, + stashedChanges: true, + }, + }; + } else { + const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : ''; + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + stashReapplied, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: `Created and checked out branch '${branchName}'${stashNote}`, + hasConflicts: false, + stashedChanges: stashReapplied, + }, + }; + } + } catch (checkoutError) { + // 7. If checkout failed and we stashed, try to restore the stash + if (didStash) { + try { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + stashPopConflicts: true, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: true, + stashPopConflictMessage: + 'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' + + 'but produced merge conflicts. Please resolve the conflicts before retrying.', + }; + } else if (!popResult.success) { + const checkoutErrorMsg = getErrorMessage(checkoutError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` + + `${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + }; + } + // popResult.success === true: stash was cleanly restored + } catch (popError) { + // popStash itself threw — build a failure result rather than letting + // the exception propagate and produce an unhandled rejection. + const checkoutErrorMsg = getErrorMessage(checkoutError); + const popErrorMsg = getErrorMessage(popError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, an error occurred while attempting to restore ` + + `your stashed changes: ${popErrorMsg} — your changes may still be saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + stashPopConflictMessage: combinedMessage, + }; + } + } + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: false, + }; + } +} diff --git a/apps/server/src/services/cherry-pick-service.ts b/apps/server/src/services/cherry-pick-service.ts new file mode 100644 index 00000000..be5dbac2 --- /dev/null +++ b/apps/server/src/services/cherry-pick-service.ts @@ -0,0 +1,179 @@ +/** + * CherryPickService - Cherry-pick git operations without HTTP + * + * Extracted from worktree cherry-pick route to encapsulate all git + * cherry-pick business logic in a single service. Follows the same + * pattern as merge-service.ts. + */ + +import { createLogger } from '@automaker/utils'; +import { execGitCommand, getCurrentBranch } from '../lib/git.js'; +import { type EventEmitter } from '../lib/events.js'; + +const logger = createLogger('CherryPickService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface CherryPickOptions { + noCommit?: boolean; +} + +export interface CherryPickResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + aborted?: boolean; + cherryPicked?: boolean; + commitHashes?: string[]; + branch?: string; + message?: string; +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * Verify that each commit hash exists in the repository. + * + * @param worktreePath - Path to the git worktree + * @param commitHashes - Array of commit hashes to verify + * @param emitter - Optional event emitter for lifecycle events + * @returns The first invalid commit hash, or null if all are valid + */ +export async function verifyCommits( + worktreePath: string, + commitHashes: string[], + emitter?: EventEmitter +): Promise { + for (const hash of commitHashes) { + try { + await execGitCommand(['rev-parse', '--verify', hash], worktreePath); + } catch { + emitter?.emit('cherry-pick:verify-failed', { worktreePath, hash }); + return hash; + } + } + return null; +} + +/** + * Run the cherry-pick operation on the given worktree. + * + * @param worktreePath - Path to the git worktree + * @param commitHashes - Array of commit hashes to cherry-pick (in order) + * @param options - Cherry-pick options (e.g., noCommit) + * @param emitter - Optional event emitter for lifecycle events + * @returns CherryPickResult with success/failure information + */ +export async function runCherryPick( + worktreePath: string, + commitHashes: string[], + options?: CherryPickOptions, + emitter?: EventEmitter +): Promise { + const args = ['cherry-pick']; + if (options?.noCommit) { + args.push('--no-commit'); + } + args.push(...commitHashes); + + emitter?.emit('cherry-pick:started', { worktreePath, commitHashes }); + + try { + await execGitCommand(args, worktreePath); + + const branch = await getCurrentBranch(worktreePath); + + if (options?.noCommit) { + const result: CherryPickResult = { + success: true, + cherryPicked: false, + commitHashes, + branch, + message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`, + }; + emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch }); + return result; + } + + const result: CherryPickResult = { + success: true, + cherryPicked: true, + commitHashes, + branch, + message: `Successfully cherry-picked ${commitHashes.length} commit(s)`, + }; + emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch }); + return result; + } catch (cherryPickError: unknown) { + // Check if this is a cherry-pick conflict + const err = cherryPickError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + const hasConflicts = + output.includes('CONFLICT') || + output.includes('cherry-pick failed') || + output.includes('could not apply'); + + if (hasConflicts) { + // Abort the cherry-pick to leave the repo in a clean state + const aborted = await abortCherryPick(worktreePath, emitter); + + if (!aborted) { + logger.error( + 'Failed to abort cherry-pick after conflict; repository may be in a dirty state', + { worktreePath } + ); + } + + emitter?.emit('cherry-pick:conflict', { + worktreePath, + commitHashes, + aborted, + stdout: err.stdout, + stderr: err.stderr, + }); + + return { + success: false, + error: aborted + ? 'Cherry-pick aborted due to conflicts; no changes were applied.' + : 'Cherry-pick failed due to conflicts and the abort also failed; repository may be in a dirty state.', + hasConflicts: true, + aborted, + }; + } + + // Non-conflict error - propagate + throw cherryPickError; + } +} + +/** + * Abort an in-progress cherry-pick operation. + * + * @param worktreePath - Path to the git worktree + * @param emitter - Optional event emitter for lifecycle events + * @returns true if abort succeeded, false if it failed (logged as warning) + */ +export async function abortCherryPick( + worktreePath: string, + emitter?: EventEmitter +): Promise { + try { + await execGitCommand(['cherry-pick', '--abort'], worktreePath); + emitter?.emit('cherry-pick:abort', { worktreePath, aborted: true }); + return true; + } catch (err: unknown) { + const error = err as { message?: string }; + logger.warn('Failed to abort cherry-pick after conflict'); + emitter?.emit('cherry-pick:abort', { + worktreePath, + aborted: false, + error: error.message ?? 'Unknown error during cherry-pick abort', + }); + return false; + } +} diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aa8afc1c..ffb07631 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -294,7 +294,15 @@ export class ClaudeUsageService { this.killPtyProcess(ptyProcess); } // Don't fail if we have data - return it instead - if (output.includes('Current session')) { + // Check cleaned output since raw output has ANSI codes between words + const cleanedForCheck = output + .replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); + if ( + cleanedForCheck.includes('Current session') || + cleanedForCheck.includes('% used') || + cleanedForCheck.includes('% left') + ) { resolve(output); } else if (hasSeenTrustPrompt) { // Trust prompt was shown but we couldn't auto-approve it @@ -320,8 +328,12 @@ export class ClaudeUsageService { output += data; // Strip ANSI codes for easier matching - // eslint-disable-next-line no-control-regex - const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + // Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries, + // then strip remaining ANSI sequences. Without this, the Claude CLI TUI output + // like "Current week (all models)" becomes "Currentweek(allmodels)". + const cleanOutput = output + .replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); // Check for specific authentication/permission errors // Must be very specific to avoid false positives from garbled terminal encoding @@ -356,7 +368,8 @@ export class ClaudeUsageService { const hasUsageIndicators = cleanOutput.includes('Current session') || (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || - // Additional patterns for winpty - look for percentage patterns + // Look for percentage patterns - allow optional whitespace between % and left/used + // since cursor movement codes may or may not create spaces after stripping /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || cleanOutput.includes('Resets in') || cleanOutput.includes('Current week'); @@ -382,12 +395,15 @@ export class ClaudeUsageService { // Handle Trust Dialog - multiple variants: // - "Do you want to work in this folder?" // - "Ready to code here?" / "I'll need permission to work with your files" + // - "Quick safety check" / "Yes, I trust this folder" // Since we are running in cwd (project dir), it is safe to approve. if ( !hasApprovedTrust && (cleanOutput.includes('Do you want to work in this folder?') || cleanOutput.includes('Ready to code here') || - cleanOutput.includes('permission to work with your files')) + cleanOutput.includes('permission to work with your files') || + cleanOutput.includes('trust this folder') || + cleanOutput.includes('safety check')) ) { hasApprovedTrust = true; hasSeenTrustPrompt = true; @@ -471,10 +487,16 @@ export class ClaudeUsageService { * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { - // First strip ANSI sequences (colors, etc) and handle CR - // eslint-disable-next-line no-control-regex + // First, convert cursor movement sequences to whitespace to preserve word boundaries. + // The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words. + // Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping. let clean = text - // CSI sequences: ESC [ ... (letter or @) + // Cursor forward (CSI n C): replace with n spaces to preserve word separation + .replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10))) + // Cursor movement (up/down/back/position): replace with newline or nothing + .replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D) + .replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f) + // Now strip remaining CSI sequences (colors, modes, etc.) .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') @@ -637,7 +659,7 @@ export class ClaudeUsageService { resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text - resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); + resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim(); } return { percentage: percentage ?? 0, resetTime, resetText }; diff --git a/apps/server/src/services/commit-log-service.ts b/apps/server/src/services/commit-log-service.ts new file mode 100644 index 00000000..14cb21d0 --- /dev/null +++ b/apps/server/src/services/commit-log-service.ts @@ -0,0 +1,161 @@ +/** + * Service for fetching commit log data from a worktree. + * + * Extracts the heavy Git command execution and parsing logic from the + * commit-log route handler so the handler only validates input, + * invokes this service, streams lifecycle events, and sends the response. + * + * Follows the same approach as branch-commit-log-service: a single + * `git log --name-only` call with custom separators to fetch both + * commit metadata and file lists, avoiding N+1 git invocations. + */ + +import { execGitCommand } from '../lib/git.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CommitLogEntry { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + subject: string; + body: string; + files: string[]; +} + +export interface CommitLogResult { + branch: string; + commits: CommitLogEntry[]; + total: number; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Fetch the commit log for a worktree (HEAD). + * + * Runs a single `git log --name-only` invocation plus `git rev-parse` + * inside the given worktree path and returns a structured result. + * + * @param worktreePath - Absolute path to the worktree / repository + * @param limit - Maximum number of commits to return (clamped 1-100) + */ +export async function getCommitLog(worktreePath: string, limit: number): Promise { + // Clamp limit to a reasonable range + const parsedLimit = Number(limit); + const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100); + + // Use custom separators to parse both metadata and file lists from + // a single git log invocation (same approach as branch-commit-log-service). + // + // -m causes merge commits to be diffed against each parent so all + // files touched by the merge are listed (without -m, --name-only + // produces no file output for merge commits because they have 2+ parents). + // This means merge commits appear multiple times in the output (once per + // parent), so we deduplicate by hash below and merge their file lists. + // We over-fetch (2x the limit) to compensate for -m duplicating merge + // commit entries, then trim the result to the requested limit. + // Use ASCII control characters as record separators – these cannot appear in + // git commit messages, so these delimiters are safe regardless of commit + // body content. %x00 and %x01 in git's format string emit literal NUL / + // SOH bytes respectively. + // + // COMMIT_SEP (\x00) – marks the start of each commit record. + // META_END (\x01) – separates commit metadata from the --name-only file list. + // + // Full per-commit layout emitted by git: + // \x00\n\n\n...\n\n\x01 + const COMMIT_SEP = '\x00'; + const META_END = '\x01'; + const fetchLimit = commitLimit * 2; + + const logOutput = await execGitCommand( + [ + 'log', + `--max-count=${fetchLimit}`, + '-m', + '--name-only', + `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`, + ], + worktreePath + ); + + // Split output into per-commit blocks and drop the empty first chunk + // (the output starts with a NUL commit separator). + const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim()); + + // Use a Map to deduplicate merge commit entries (which appear once per + // parent when -m is used) while preserving insertion order. + const commitMap = new Map(); + + for (const block of commitBlocks) { + const metaEndIdx = block.indexOf(META_END); + if (metaEndIdx === -1) continue; // malformed block, skip + + // --- Parse metadata (everything before the META_END delimiter) --- + const metaRaw = block.substring(0, metaEndIdx); + const metaLines = metaRaw.split('\n'); + + // The first line may be empty (newline right after COMMIT_SEP), skip it + const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== ''); + if (nonEmptyStart === -1) continue; + + const fields = metaLines.slice(nonEmptyStart); + if (fields.length < 6) continue; // need at least hash..subject + + const hash = fields[0].trim(); + if (!hash) continue; // defensive: skip if hash is empty + const shortHash = fields[1]?.trim() ?? ''; + const author = fields[2]?.trim() ?? ''; + const authorEmail = fields[3]?.trim() ?? ''; + const date = fields[4]?.trim() ?? ''; + const subject = fields[5]?.trim() ?? ''; + const body = fields.slice(6).join('\n').trim(); + + // --- Parse file list (everything after the META_END delimiter) --- + const filesRaw = block.substring(metaEndIdx + META_END.length); + const blockFiles = filesRaw + .trim() + .split('\n') + .filter((f) => f.trim()); + + // Merge file lists for duplicate entries (merge commits with -m) + const existing = commitMap.get(hash); + if (existing) { + // Add new files to the existing entry's file set + const fileSet = new Set(existing.files); + for (const f of blockFiles) fileSet.add(f); + existing.files = [...fileSet]; + } else { + commitMap.set(hash, { + hash, + shortHash, + author, + authorEmail, + date, + subject, + body, + files: [...new Set(blockFiles)], + }); + } + } + + // Trim to the requested limit (we over-fetched to account for -m duplicates) + const commits = [...commitMap.values()].slice(0, commitLimit); + + // Get current branch name + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const branch = branchOutput.trim(); + + return { + branch, + commits, + total: commits.length, + }; +} diff --git a/apps/server/src/services/concurrency-manager.ts b/apps/server/src/services/concurrency-manager.ts new file mode 100644 index 00000000..b64456a1 --- /dev/null +++ b/apps/server/src/services/concurrency-manager.ts @@ -0,0 +1,272 @@ +/** + * ConcurrencyManager - Manages running feature slots with lease-based reference counting + * + * Extracted from AutoModeService to provide a standalone service for tracking + * running feature execution with proper lease counting to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * Key behaviors: + * - acquire() with existing entry + allowReuse: increment leaseCount, return existing + * - acquire() with existing entry + no allowReuse: throw Error('already running') + * - release() decrements leaseCount, only deletes at 0 + * - release() with force:true bypasses leaseCount check + */ + +import type { ModelProvider } from '@automaker/types'; + +/** + * Function type for getting the current branch of a project. + * Injected to allow for testing and decoupling from git operations. + */ +export type GetCurrentBranchFn = (projectPath: string) => Promise; + +/** + * Represents a running feature execution with all tracking metadata + */ +export interface RunningFeature { + featureId: string; + projectPath: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + isAutoMode: boolean; + startTime: number; + leaseCount: number; + model?: string; + provider?: ModelProvider; +} + +/** + * Parameters for acquiring a running feature slot + */ +export interface AcquireParams { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + abortController?: AbortController; +} + +/** + * ConcurrencyManager manages the running features Map with lease-based reference counting. + * + * This supports nested execution patterns where a feature may be acquired multiple times + * (e.g., during resume operations) and should only be released when all references are done. + */ +export class ConcurrencyManager { + private runningFeatures = new Map(); + private getCurrentBranch: GetCurrentBranchFn; + + /** + * @param getCurrentBranch - Function to get the current branch for a project. + * If not provided, defaults to returning 'main'. + */ + constructor(getCurrentBranch?: GetCurrentBranchFn) { + this.getCurrentBranch = getCurrentBranch ?? (() => Promise.resolve('main')); + } + + /** + * Acquire a slot in the runningFeatures map for a feature. + * Implements reference counting via leaseCount to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * @param params.featureId - ID of the feature to track + * @param params.projectPath - Path to the project + * @param params.isAutoMode - Whether this is an auto-mode execution + * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features + * @param params.abortController - Optional abort controller to use + * @returns The RunningFeature entry (existing or newly created) + * @throws Error if feature is already running and allowReuse is false + */ + acquire(params: AcquireParams): RunningFeature { + const existing = this.runningFeatures.get(params.featureId); + if (existing) { + if (!params.allowReuse) { + throw new Error('already running'); + } + existing.leaseCount += 1; + return existing; + } + + const abortController = params.abortController ?? new AbortController(); + const entry: RunningFeature = { + featureId: params.featureId, + projectPath: params.projectPath, + worktreePath: null, + branchName: null, + abortController, + isAutoMode: params.isAutoMode, + startTime: Date.now(), + leaseCount: 1, + }; + this.runningFeatures.set(params.featureId, entry); + return entry; + } + + /** + * Release a slot in the runningFeatures map for a feature. + * Decrements leaseCount and only removes the entry when it reaches zero, + * unless force option is used. + * + * @param featureId - ID of the feature to release + * @param options.force - If true, immediately removes the entry regardless of leaseCount + */ + release(featureId: string, options?: { force?: boolean }): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + if (options?.force) { + this.runningFeatures.delete(featureId); + return; + } + + entry.leaseCount -= 1; + if (entry.leaseCount <= 0) { + this.runningFeatures.delete(featureId); + } + } + + /** + * Check if a feature is currently running + * + * @param featureId - ID of the feature to check + * @returns true if the feature is in the runningFeatures map + */ + isRunning(featureId: string): boolean { + return this.runningFeatures.has(featureId); + } + + /** + * Get the RunningFeature entry for a feature + * + * @param featureId - ID of the feature + * @returns The RunningFeature entry or undefined if not running + */ + getRunningFeature(featureId: string): RunningFeature | undefined { + return this.runningFeatures.get(featureId); + } + + /** + * Get count of running features for a specific project + * + * @param projectPath - The project path to count features for + * @returns Number of running features for the project + */ + getRunningCount(projectPath: string): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath === projectPath) { + count++; + } + } + return count; + } + + /** + * Get count of running features for a specific worktree + * + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * (features without branchName or matching primary branch) + * @param options.autoModeOnly - If true, only count features started by auto mode. + * Note: The auto-loop coordinator now counts ALL + * running features (not just auto-mode) to ensure + * total system load is respected. This option is + * retained for other callers that may need filtered counts. + * @returns Number of running features for the worktree + */ + async getRunningCountForWorktree( + projectPath: string, + branchName: string | null, + options?: { autoModeOnly?: boolean } + ): Promise { + // Get the actual primary branch name for the project + const primaryBranch = await this.getCurrentBranch(projectPath); + + let count = 0; + for (const [, feature] of this.runningFeatures) { + // If autoModeOnly is set, skip manually started features + if (options?.autoModeOnly && !feature.isAutoMode) { + continue; + } + + // Filter by project path AND branchName to get accurate worktree-specific count + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: match features with branchName === null OR branchName matching primary branch + const isPrimaryBranch = + featureBranch === null || (primaryBranch && featureBranch === primaryBranch); + if (feature.projectPath === projectPath && isPrimaryBranch) { + count++; + } + } else { + // Feature worktree: exact match + if (feature.projectPath === projectPath && featureBranch === branchName) { + count++; + } + } + } + return count; + } + + /** + * Get all currently running features + * + * @returns Array of all RunningFeature entries + */ + getAllRunning(): RunningFeature[] { + return Array.from(this.runningFeatures.values()); + } + + /** + * Get running feature IDs for a specific worktree, with proper primary branch normalization. + * + * When branchName is null (main worktree), matches features with branchName === null + * OR branchName matching the primary branch (e.g., "main", "master"). + * + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * @returns Array of feature IDs running in the specified worktree + */ + async getRunningFeaturesForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + const primaryBranch = await this.getCurrentBranch(projectPath); + const featureIds: string[] = []; + + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath !== projectPath) continue; + const featureBranch = feature.branchName ?? null; + + if (branchName === null) { + // Main worktree: match features with null branchName OR primary branch name + const isPrimaryBranch = + featureBranch === null || (primaryBranch && featureBranch === primaryBranch); + if (isPrimaryBranch) featureIds.push(feature.featureId); + } else { + // Feature worktree: exact match + if (featureBranch === branchName) featureIds.push(feature.featureId); + } + } + + return featureIds; + } + + /** + * Update properties of a running feature + * + * @param featureId - ID of the feature to update + * @param updates - Partial RunningFeature properties to update + */ + updateRunningFeature(featureId: string, updates: Partial): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + Object.assign(entry, updates); + } +} diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index d81e539c..c9637c42 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -19,12 +19,81 @@ const logger = createLogger('DevServerService'); // Maximum scrollback buffer size (characters) - matches TerminalService pattern const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server +// Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded. +// This handles cases where the dev server output format is not recognized by any pattern. +const URL_DETECTION_TIMEOUT_MS = 30_000; + +// URL patterns for detecting full URLs from dev server output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +// Ordered from most specific (framework-specific) to least specific. +const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..." + { + pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i, + description: 'Vite/Nuxt/SvelteKit/Astro/Angular format', + }, + // Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + // Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000" + { + pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, + description: 'Next.js format', + }, + // Remix format: "started at http://localhost:3000" + // Django format: "Starting development server at http://127.0.0.1:8000/" + // Rails / Puma: "Listening on http://127.0.0.1:3000" + // Generic: "listening at http://...", "available at http://...", "running at http://..." + { + pattern: + /(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i, + description: 'Generic "starting/started/listening at" format', + }, + // PHP built-in server: "Development Server (http://localhost:8000) started" + { + pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i, + description: 'PHP server format', + }, + // Webpack Dev Server: "Project is running at http://localhost:8080/" + { + pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i, + description: 'Webpack/generic "running at" format', + }, + // Go / Rust / generic: "Serving on http://...", "Server on http://..." + { + pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i, + description: 'Generic "serving on" format', + }, + // Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0) + // This catches anything that looks like a dev server URL + { + pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i, + description: 'Generic localhost URL with port', + }, +]; + +// Port-only patterns for detecting port numbers from dev server output +// when a full URL is not present in the output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // "listening on port 3000", "server on port 3000", "started on port 3000" + { + pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i, + description: '"listening on port" format', + }, + // "Port: 3000", "port 3000" (at start of line or after whitespace) + { + pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im, + description: '"port:" format', + }, +]; + // Throttle output to prevent overwhelming WebSocket under heavy load const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency export interface DevServerInfo { worktreePath: string; + /** The port originally reserved by findAvailablePort() – never mutated after startDevServer sets it */ + allocatedPort: number; port: number; url: string; process: ChildProcess | null; @@ -39,6 +108,8 @@ export interface DevServerInfo { stopping: boolean; // Flag to indicate if URL has been detected from output urlDetected: boolean; + // Timer for URL detection timeout fallback + urlDetectionTimeout: NodeJS.Timeout | null; } // Port allocation starts at 3001 to avoid conflicts with common dev ports @@ -61,6 +132,32 @@ class DevServerService { this.emitter = emitter; } + /** + * Prune a stale server entry whose process has exited without cleanup. + * Clears any pending timers, removes the port from allocatedPorts, deletes + * the entry from runningServers, and emits the "dev-server:stopped" event + * so all callers consistently notify the frontend when pruning entries. + * + * @param worktreePath - The key used in runningServers + * @param server - The DevServerInfo entry to prune + */ + private pruneStaleServer(worktreePath: string, server: DevServerInfo): void { + if (server.flushTimeout) clearTimeout(server.flushTimeout); + if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout); + // Use allocatedPort (immutable) to free the reserved slot; server.port may have + // been mutated by detectUrlFromOutput to reflect the actual detected port. + this.allocatedPorts.delete(server.allocatedPort); + this.runningServers.delete(worktreePath); + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, // Report the externally-visible (detected) port + exitCode: server.process?.exitCode ?? null, + timestamp: new Date().toISOString(), + }); + } + } + /** * Append data to scrollback buffer with size limit enforcement * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE @@ -105,9 +202,52 @@ class DevServerService { } } + /** + * Strip ANSI escape codes from a string + * Dev server output often contains color codes that can interfere with URL detection + */ + private stripAnsi(str: string): string { + // Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes + // eslint-disable-next-line no-control-regex + return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, ''); + } + + /** + * Extract port number from a URL string. + * Returns the explicit port if present, or null if no port is specified. + * Default protocol ports (80/443) are intentionally NOT returned to avoid + * overwriting allocated dev server ports with protocol defaults. + */ + private extractPortFromUrl(url: string): number | null { + try { + const parsed = new URL(url); + if (parsed.port) { + return parseInt(parsed.port, 10); + } + return null; + } catch { + return null; + } + } + /** * Detect actual server URL from output - * Parses stdout/stderr for common URL patterns from dev servers + * Parses stdout/stderr for common URL patterns from dev servers. + * + * Supports detection of URLs from: + * - Vite: "Local: http://localhost:5173/" + * - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + * - Nuxt: "Local: http://localhost:3000/" + * - Remix: "started at http://localhost:3000" + * - Astro: "Local http://localhost:4321/" + * - SvelteKit: "Local: http://localhost:5173/" + * - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" + * - Angular: "Local: http://localhost:4200/" + * - Express/Fastify/Koa: "Server listening on port 3000" + * - Django: "Starting development server at http://127.0.0.1:8000/" + * - Rails: "Listening on http://127.0.0.1:3000" + * - PHP: "Development Server (http://localhost:8000) started" + * - Generic: Any localhost URL with a port */ private detectUrlFromOutput(server: DevServerInfo, content: string): void { // Skip if URL already detected @@ -115,39 +255,107 @@ class DevServerService { return; } - // Common URL patterns from various dev servers: - // - Vite: "Local: http://localhost:5173/" - // - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" - // - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" - // - Generic: Any http:// or https:// URL - const urlPatterns = [ - /(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format - /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format - /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL - /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL - ]; + // Strip ANSI escape codes to prevent color codes from breaking regex matching + const cleanContent = this.stripAnsi(content); - for (const pattern of urlPatterns) { - const match = content.match(pattern); + // Phase 1: Try to detect a full URL from output + // Patterns are defined at module level (URL_PATTERNS) and reused across calls + for (const { pattern, description } of URL_PATTERNS) { + const match = cleanContent.match(pattern); if (match && match[1]) { - const detectedUrl = match[1].trim(); - // Validate it looks like a reasonable URL + let detectedUrl = match[1].trim(); + // Remove trailing punctuation that might have been captured + detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, ''); + if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { + // Normalize 0.0.0.0 to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/0\.0\.0\.0(:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::] to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::1] (IPv6 loopback) to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::1\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + server.url = detectedUrl; server.urlDetected = true; - logger.info( - `Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})` - ); + + // Clear the URL detection timeout since we found the URL + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + + // Update the port to match the detected URL's actual port + const detectedPort = this.extractPortFromUrl(detectedUrl); + if (detectedPort && detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server URL via ${description}: ${detectedUrl}`); // Emit URL update event if (this.emitter) { this.emitter.emit('dev-server:url-detected', { worktreePath: server.worktreePath, url: detectedUrl, + port: server.port, timestamp: new Date().toISOString(), }); } - break; + return; + } + } + } + + // Phase 2: Try to detect just a port number from output (no full URL) + // Some servers only print "listening on port 3000" without a full URL + // Patterns are defined at module level (PORT_PATTERNS) and reused across calls + for (const { pattern, description } of PORT_PATTERNS) { + const match = cleanContent.match(pattern); + if (match && match[1]) { + const detectedPort = parseInt(match[1], 10); + // Sanity check: port should be in a reasonable range + if (detectedPort > 0 && detectedPort <= 65535) { + const detectedUrl = `http://localhost:${detectedPort}`; + server.url = detectedUrl; + server.urlDetected = true; + + // Clear the URL detection timeout since we found the port + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + + if (detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`); + + // Emit URL update event + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: server.worktreePath, + url: detectedUrl, + port: server.port, + timestamp: new Date().toISOString(), + }); + } + return; } } } @@ -246,7 +454,7 @@ class DevServerService { // No process found on port, which is fine } } - } catch (error) { + } catch { // Ignore errors - port might not have any process logger.debug(`No process to kill on port ${port}`); } @@ -498,6 +706,7 @@ class DevServerService { const hostname = process.env.HOSTNAME || 'localhost'; const serverInfo: DevServerInfo = { worktreePath, + allocatedPort: port, // Immutable: records which port we reserved; never changed after this point port, url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput process: devProcess, @@ -507,6 +716,7 @@ class DevServerService { flushTimeout: null, stopping: false, urlDetected: false, // Will be set to true when actual URL is detected from output + urlDetectionTimeout: null, // Will be set after server starts successfully }; // Capture stdout with buffer management and event emission @@ -530,18 +740,24 @@ class DevServerService { serverInfo.flushTimeout = null; } + // Clear URL detection timeout to prevent stale fallback emission + if (serverInfo.urlDetectionTimeout) { + clearTimeout(serverInfo.urlDetectionTimeout); + serverInfo.urlDetectionTimeout = null; + } + // Emit stopped event (only if not already stopping - prevents duplicate events) if (this.emitter && !serverInfo.stopping) { this.emitter.emit('dev-server:stopped', { worktreePath, - port, + port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) exitCode, error: errorMessage, timestamp: new Date().toISOString(), }); } - this.allocatedPorts.delete(port); + this.allocatedPorts.delete(serverInfo.allocatedPort); this.runningServers.delete(worktreePath); }; @@ -587,6 +803,43 @@ class DevServerService { }); } + // Set up URL detection timeout fallback. + // If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if + // the allocated port is actually in use (server probably started successfully) + // and emit a url-detected event with the allocated port as fallback. + // Also re-scan the scrollback buffer in case the URL was printed before + // our patterns could match (e.g., it was split across multiple data chunks). + serverInfo.urlDetectionTimeout = setTimeout(() => { + serverInfo.urlDetectionTimeout = null; + + // Only run fallback if server is still running and URL wasn't detected + if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) { + return; + } + + // Re-scan the entire scrollback buffer for URL patterns + // This catches cases where the URL was split across multiple output chunks + logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`); + this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer); + + // If still not detected after full rescan, use the allocated port as fallback + if (!serverInfo.urlDetected) { + logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); + const fallbackUrl = `http://${hostname}:${port}`; + serverInfo.url = fallbackUrl; + serverInfo.urlDetected = true; + + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath, + url: fallbackUrl, + port, + timestamp: new Date().toISOString(), + }); + } + } + }, URL_DETECTION_TIMEOUT_MS); + return { success: true, result: { @@ -632,6 +885,12 @@ class DevServerService { server.flushTimeout = null; } + // Clean up URL detection timeout + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + // Clear any pending output buffer server.outputBuffer = ''; @@ -650,8 +909,10 @@ class DevServerService { server.process.kill('SIGTERM'); } - // Free the port - this.allocatedPorts.delete(server.port); + // Free the originally-reserved port slot (allocatedPort is immutable and always + // matches what was added to allocatedPorts in startDevServer; server.port may + // have been updated by detectUrlFromOutput to the actual detected port). + this.allocatedPorts.delete(server.allocatedPort); this.runningServers.delete(worktreePath); return { @@ -665,6 +926,7 @@ class DevServerService { /** * List all running dev servers + * Also verifies that each server's process is still alive, removing stale entries */ listDevServers(): { success: boolean; @@ -673,13 +935,38 @@ class DevServerService { worktreePath: string; port: number; url: string; + urlDetected: boolean; + startedAt: string; }>; }; } { + // Prune any servers whose process has died without us being notified + // This handles edge cases where the process exited but the 'exit' event was missed + const stalePaths: string[] = []; + for (const [worktreePath, server] of this.runningServers) { + // Check if exitCode is a number (not null/undefined) - indicates process has exited + if (server.process && typeof server.process.exitCode === 'number') { + logger.info( + `Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})` + ); + stalePaths.push(worktreePath); + } + } + for (const stalePath of stalePaths) { + const server = this.runningServers.get(stalePath); + if (server) { + // Delegate to the shared helper so timers, ports, and the stopped event + // are all handled consistently with isRunning and getServerInfo. + this.pruneStaleServer(stalePath, server); + } + } + const servers = Array.from(this.runningServers.values()).map((s) => ({ worktreePath: s.worktreePath, port: s.port, url: s.url, + urlDetected: s.urlDetected, + startedAt: s.startedAt.toISOString(), })); return { @@ -689,17 +976,33 @@ class DevServerService { } /** - * Check if a worktree has a running dev server + * Check if a worktree has a running dev server. + * Also prunes stale entries where the process has exited. */ isRunning(worktreePath: string): boolean { - return this.runningServers.has(worktreePath); + const server = this.runningServers.get(worktreePath); + if (!server) return false; + // Prune stale entry if the process has exited + if (server.process && typeof server.process.exitCode === 'number') { + this.pruneStaleServer(worktreePath, server); + return false; + } + return true; } /** - * Get info for a specific worktree's dev server + * Get info for a specific worktree's dev server. + * Also prunes stale entries where the process has exited. */ getServerInfo(worktreePath: string): DevServerInfo | undefined { - return this.runningServers.get(worktreePath); + const server = this.runningServers.get(worktreePath); + if (!server) return undefined; + // Prune stale entry if the process has exited + if (server.process && typeof server.process.exitCode === 'number') { + this.pruneStaleServer(worktreePath, server); + return undefined; + } + return server; } /** @@ -727,6 +1030,15 @@ class DevServerService { }; } + // Prune stale entry if the process has been killed or has exited + if (server.process && (server.process.killed || server.process.exitCode != null)) { + this.pruneStaleServer(worktreePath, server); + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + return { success: true, result: { diff --git a/apps/server/src/services/event-history-service.ts b/apps/server/src/services/event-history-service.ts index b983af09..a7091725 100644 --- a/apps/server/src/services/event-history-service.ts +++ b/apps/server/src/services/event-history-service.ts @@ -13,12 +13,7 @@ import { createLogger } from '@automaker/utils'; import * as secureFs from '../lib/secure-fs.js'; -import { - getEventHistoryDir, - getEventHistoryIndexPath, - getEventPath, - ensureEventHistoryDir, -} from '@automaker/platform'; +import { getEventHistoryIndexPath, getEventPath, ensureEventHistoryDir } from '@automaker/platform'; import type { StoredEvent, StoredEventIndex, diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 2aedc7f4..376da964 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -60,10 +60,13 @@ interface AutoModeEventPayload { featureId?: string; featureName?: string; passes?: boolean; + executionMode?: 'auto' | 'manual'; message?: string; error?: string; errorType?: string; projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; } /** @@ -75,6 +78,40 @@ interface FeatureCreatedPayload { projectPath: string; } +/** + * Feature status changed event payload structure + */ +interface FeatureStatusChangedPayload { + featureId: string; + projectPath: string; + status: string; +} + +/** + * Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload + */ +function isFeatureStatusChangedPayload( + payload: AutoModeEventPayload +): payload is AutoModeEventPayload & FeatureStatusChangedPayload { + return ( + typeof payload.featureId === 'string' && + typeof payload.projectPath === 'string' && + typeof payload.status === 'string' + ); +} + +/** + * Feature completed event payload structure + */ +interface FeatureCompletedPayload { + featureId: string; + featureName?: string; + projectPath: string; + passes?: boolean; + message?: string; + executionMode?: 'auto' | 'manual'; +} + /** * Event Hook Service * @@ -82,12 +119,30 @@ interface FeatureCreatedPayload { * Also stores events to history for debugging and replay. */ export class EventHookService { + /** Feature status that indicates agent work is done and awaiting human review (tests skipped) */ + private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval'; + /** Feature status that indicates agent work passed automated verification */ + private static readonly STATUS_VERIFIED = 'verified'; + private emitter: EventEmitter | null = null; private settingsService: SettingsService | null = null; private eventHistoryService: EventHistoryService | null = null; private featureLoader: FeatureLoader | null = null; private unsubscribe: (() => void) | null = null; + /** + * Track feature IDs that have already had hooks fired via auto_mode_feature_complete + * to prevent double-firing when feature_status_changed also fires for the same feature. + * Entries are automatically cleaned up after 30 seconds. + */ + private recentlyHandledFeatures = new Set(); + + /** + * Timer IDs for pending cleanup of recentlyHandledFeatures entries, + * keyed by featureId. Stored so they can be cancelled in destroy(). + */ + private recentlyHandledTimers = new Map>(); + /** * Initialize the service with event emitter, settings service, event history service, and feature loader */ @@ -108,6 +163,8 @@ export class EventHookService { this.handleAutoModeEvent(payload as AutoModeEventPayload); } else if (type === 'feature:created') { this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload); + } else if (type === 'feature:completed') { + this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload); } }); @@ -122,6 +179,12 @@ export class EventHookService { this.unsubscribe(); this.unsubscribe = null; } + // Cancel all pending cleanup timers to avoid cross-session mutations + for (const timerId of this.recentlyHandledTimers.values()) { + clearTimeout(timerId); + } + this.recentlyHandledTimers.clear(); + this.recentlyHandledFeatures.clear(); this.emitter = null; this.settingsService = null; this.eventHistoryService = null; @@ -139,15 +202,31 @@ export class EventHookService { switch (payload.type) { case 'auto_mode_feature_complete': + // Only map explicit auto-mode completion events. + // Manual feature completions are emitted as feature:completed. + if (payload.executionMode !== 'auto') return; trigger = payload.passes ? 'feature_success' : 'feature_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } break; case 'auto_mode_error': // Feature-level error (has featureId) vs auto-mode level error trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } break; case 'auto_mode_idle': trigger = 'auto_mode_complete'; break; + case 'feature_status_changed': + if (isFeatureStatusChangedPayload(payload)) { + this.handleFeatureStatusChanged(payload); + } + return; default: // Other event types don't trigger hooks return; @@ -170,13 +249,15 @@ export class EventHookService { // Build context for variable substitution // Use loaded featureName (from feature.title) or fall back to payload.featureName + // Only populate error/errorType for error triggers - don't leak success messages into error fields + const isErrorTrigger = trigger === 'feature_error' || trigger === 'auto_mode_error'; const context: HookContext = { featureId: payload.featureId, featureName: featureName || payload.featureName, projectPath: payload.projectPath, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, - error: payload.error || payload.message, - errorType: payload.errorType, + error: isErrorTrigger ? payload.error || payload.message : undefined, + errorType: isErrorTrigger ? payload.errorType : undefined, timestamp: new Date().toISOString(), eventType: trigger, }; @@ -185,6 +266,46 @@ export class EventHookService { await this.executeHooksForTrigger(trigger, context, { passes: payload.passes }); } + /** + * Handle feature:completed events and trigger matching hooks + */ + private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise { + if (!payload.featureId || !payload.projectPath) return; + + // Mark as handled to prevent duplicate firing if feature_status_changed also fires + this.markFeatureHandled(payload.featureId); + + const passes = payload.passes ?? true; + const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error'; + + // Load feature name if we have featureId but no featureName + let featureName: string | undefined = undefined; + if (payload.projectPath && this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error); + } + } + + const isErrorTrigger = trigger === 'feature_error'; + const context: HookContext = { + featureId: payload.featureId, + featureName: featureName || payload.featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + error: isErrorTrigger ? payload.message : undefined, + errorType: undefined, + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + await this.executeHooksForTrigger(trigger, context, { passes }); + } + /** * Handle feature:created events and trigger matching hooks */ @@ -201,6 +322,74 @@ export class EventHookService { await this.executeHooksForTrigger('feature_created', context); } + /** + * Handle feature_status_changed events for non-auto-mode feature completion. + * + * Auto-mode features already emit auto_mode_feature_complete which triggers hooks. + * This handler catches manual (non-auto-mode) feature completions by detecting + * status transitions to completion states (verified, waiting_approval). + */ + private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise { + // Skip if this feature was already handled via auto_mode_feature_complete + if (this.recentlyHandledFeatures.has(payload.featureId)) { + return; + } + + let trigger: EventHookTrigger | null = null; + + if ( + payload.status === EventHookService.STATUS_VERIFIED || + payload.status === EventHookService.STATUS_WAITING_APPROVAL + ) { + trigger = 'feature_success'; + } else { + // Only completion statuses trigger hooks from status changes + return; + } + + // Load feature name + let featureName: string | undefined = undefined; + if (this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error); + } + } + + const context: HookContext = { + featureId: payload.featureId, + featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + await this.executeHooksForTrigger(trigger, context, { passes: true }); + } + + /** + * Mark a feature as recently handled to prevent double-firing hooks. + * Entries are cleaned up after 30 seconds. + */ + private markFeatureHandled(featureId: string): void { + // Cancel any existing timer for this feature before setting a new one + const existing = this.recentlyHandledTimers.get(featureId); + if (existing !== undefined) { + clearTimeout(existing); + } + this.recentlyHandledFeatures.add(featureId); + const timerId = setTimeout(() => { + this.recentlyHandledFeatures.delete(featureId); + this.recentlyHandledTimers.delete(featureId); + }, 30000); + this.recentlyHandledTimers.set(featureId, timerId); + } + /** * Execute all enabled hooks matching the given trigger and store event to history */ diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts new file mode 100644 index 00000000..633ac809 --- /dev/null +++ b/apps/server/src/services/execution-service.ts @@ -0,0 +1,560 @@ +/** + * ExecutionService - Feature execution lifecycle coordination + */ + +import path from 'path'; +import type { Feature } from '@automaker/types'; +import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { getFeatureDir } from '@automaker/platform'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; +import { validateWorkingDirectory } from '../lib/sdk-options.js'; +import { extractSummary } from './spec-parser.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; +import type { WorktreeResolver } from './worktree-resolver.js'; +import type { SettingsService } from './settings-service.js'; +import { pipelineService } from './pipeline-service.js'; + +// Re-export callback types from execution-types.ts for backward compatibility +export type { + RunAgentFn, + ExecutePipelineFn, + UpdateFeatureStatusFn, + LoadFeatureFn, + GetPlanningPromptPrefixFn, + SaveFeatureSummaryFn, + RecordLearningsFn, + ContextExistsFn, + ResumeFeatureFn, + TrackFailureFn, + SignalPauseFn, + RecordSuccessFn, + SaveExecutionStateFn, + LoadContextFilesFn, +} from './execution-types.js'; + +import type { + RunAgentFn, + ExecutePipelineFn, + UpdateFeatureStatusFn, + LoadFeatureFn, + GetPlanningPromptPrefixFn, + SaveFeatureSummaryFn, + RecordLearningsFn, + ContextExistsFn, + ResumeFeatureFn, + TrackFailureFn, + SignalPauseFn, + RecordSuccessFn, + SaveExecutionStateFn, + LoadContextFilesFn, +} from './execution-types.js'; + +const logger = createLogger('ExecutionService'); + +/** Marker written by agent-executor for each tool invocation. */ +const TOOL_USE_MARKER = '🔧 Tool:'; + +/** Minimum trimmed output length to consider agent work meaningful. */ +const MIN_MEANINGFUL_OUTPUT_LENGTH = 200; + +export class ExecutionService { + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private worktreeResolver: WorktreeResolver, + private settingsService: SettingsService | null, + // Callback dependencies for delegation + private runAgentFn: RunAgentFn, + private executePipelineFn: ExecutePipelineFn, + private updateFeatureStatusFn: UpdateFeatureStatusFn, + private loadFeatureFn: LoadFeatureFn, + private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn, + private saveFeatureSummaryFn: SaveFeatureSummaryFn, + private recordLearningsFn: RecordLearningsFn, + private contextExistsFn: ContextExistsFn, + private resumeFeatureFn: ResumeFeatureFn, + private trackFailureFn: TrackFailureFn, + private signalPauseFn: SignalPauseFn, + private recordSuccessFn: RecordSuccessFn, + private saveExecutionStateFn: SaveExecutionStateFn, + private loadContextFilesFn: LoadContextFilesFn + ) {} + + private acquireRunningFeature(options: { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + }): RunningFeature { + return this.concurrencyManager.acquire(options); + } + + private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { + this.concurrencyManager.release(featureId, options); + } + + private extractTitleFromDescription(description: string | undefined): string { + if (!description?.trim()) return 'Untitled Feature'; + const firstLine = description.split('\n')[0].trim(); + return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; + } + + buildFeaturePrompt( + feature: Feature, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } + ): string { + const title = this.extractTitleFromDescription(feature.description); + + let prompt = `## Feature Implementation Task + +**Feature ID:** ${feature.id} +**Title:** ${title} +**Description:** ${feature.description} +`; + + if (feature.spec) { + prompt += ` +**Specification:** +${feature.spec} +`; + } + + if (feature.imagePaths && feature.imagePaths.length > 0) { + const imagesList = feature.imagePaths + .map((img, idx) => { + const imgPath = typeof img === 'string' ? img : img.path; + const filename = + typeof img === 'string' + ? imgPath.split('/').pop() + : img.filename || imgPath.split('/').pop(); + const mimeType = typeof img === 'string' ? 'image/*' : img.mimeType || 'image/*'; + return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${imgPath}`; + }) + .join('\n'); + prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`; + } + + prompt += feature.skipTests + ? `\n${taskExecutionPrompts.implementationInstructions}` + : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; + return prompt; + } + + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } + ): Promise { + const tempRunningFeature = this.acquireRunningFeature({ + featureId, + projectPath, + isAutoMode, + allowReuse: options?._calledInternally, + }); + const abortController = tempRunningFeature.abortController; + if (isAutoMode) await this.saveExecutionStateFn(projectPath); + let feature: Feature | null = null; + + try { + validateWorkingDirectory(projectPath); + feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + + // Update status to in_progress immediately after acquiring the feature. + // This prevents a race condition where the UI reloads features and sees the + // feature still in 'backlog' status while it's actually being executed. + // Only do this for the initial call (not internal/recursive calls which would + // redundantly update the status). + if ( + !options?._calledInternally && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted') + ) { + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + } + + if (!options?.continuationPrompt) { + if (feature.planSpec?.status === 'approved') { + const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt + .replace(/\{\{userFeedback\}\}/g, '') + .replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || ''); + return await this.executeFeature( + projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + { continuationPrompt, _calledInternally: true } + ); + } + if (await this.contextExistsFn(projectPath, featureId)) { + return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true); + } + } + + let worktreePath: string | null = providedWorktreePath ?? null; + const branchName = feature.branchName; + if (!worktreePath && useWorktrees && branchName) { + worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); + if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + tempRunningFeature.worktreePath = worktreePath; + tempRunningFeature.branchName = branchName ?? null; + // Ensure status is in_progress (may already be set from the early update above, + // but internal/recursive calls skip the early update and need it here). + // Mirror the external guard: only transition when the feature is still in + // backlog, ready, or interrupted to avoid overwriting a concurrent terminal status. + if ( + options?._calledInternally && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted') + ) { + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + } + this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: feature.branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Loading...', + description: feature.description || 'Feature is starting', + }, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[ExecutionService]' + ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[ExecutionService]' + ); + const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); + let prompt: string; + const contextResult = await this.loadContextFilesFn({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + taskContext: { + title: feature.title ?? '', + description: feature.description ?? '', + }, + }); + const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + + if (options?.continuationPrompt) { + prompt = options.continuationPrompt; + } else { + prompt = + (await this.getPlanningPromptPrefixFn(feature)) + + this.buildFeaturePrompt(feature, prompts.taskExecution); + if (feature.planningMode && feature.planningMode !== 'skip') { + this.eventBus.emitAutoModeEvent('planning_started', { + featureId: feature.id, + mode: feature.planningMode, + message: `Starting ${feature.planningMode} planning phase`, + }); + } + } + + const imagePaths = feature.imagePaths?.map((img) => + typeof img === 'string' ? img : img.path + ); + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + tempRunningFeature.model = model; + tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model); + + await this.runAgentFn( + workDir, + featureId, + prompt, + abortController, + projectPath, + imagePaths, + model, + { + projectPath, + planningMode: feature.planningMode, + requirePlanApproval: feature.requirePlanApproval, + systemPrompt: combinedSystemPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + branchName: feature.branchName ?? null, + } + ); + + // Check for incomplete tasks after agent execution. + // The agent may have finished early (hit max turns, decided it was done, etc.) + // while tasks are still pending. If so, re-run the agent to complete remaining tasks. + const MAX_TASK_RETRY_ATTEMPTS = 3; + let taskRetryAttempts = 0; + while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) { + const currentFeature = await this.loadFeatureFn(projectPath, featureId); + if (!currentFeature?.planSpec?.tasks) break; + + const pendingTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (pendingTasks.length === 0) break; + + taskRetryAttempts++; + const totalTasks = currentFeature.planSpec.tasks.length; + const completedTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + logger.info( + `[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})` + ); + + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName: feature.branchName ?? null, + content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`, + projectPath, + }); + + // Build a continuation prompt that tells the agent to finish remaining tasks + const remainingTasksList = pendingTasks + .map((t) => `- ${t.id}: ${t.description} (${t.status})`) + .join('\n'); + + const continuationPrompt = `## Continue Implementation - Incomplete Tasks + +The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks. + +**Completed:** ${completedTasks}/${totalTasks} tasks +**Remaining tasks:** +${remainingTasksList} + +Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`; + + await this.runAgentFn( + workDir, + featureId, + continuationPrompt, + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + systemPrompt: combinedSystemPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + branchName: feature.branchName ?? null, + } + ); + } + + // Log if tasks are still incomplete after retry attempts + if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) { + const finalFeature = await this.loadFeatureFn(projectPath, featureId); + const stillPending = finalFeature?.planSpec?.tasks?.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (stillPending && stillPending.length > 0) { + logger.warn( + `[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.` + ); + } + } + + const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + const sortedSteps = [...(pipelineConfig?.steps || [])] + .sort((a, b) => a.order - b.order) + .filter((step) => !excludedStepIds.has(step.id)); + if (sortedSteps.length > 0) { + await this.executePipelineFn({ + projectPath, + featureId, + feature, + steps: sortedSteps, + workDir, + worktreePath, + branchName: feature.branchName ?? null, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + testAttempts: 0, + maxTestAttempts: 5, + }); + // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it + const refreshed = await this.loadFeatureFn(projectPath, featureId); + if (refreshed?.status === 'merge_conflict') { + return; + } + } + + // Read agent output before determining final status. + // CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing + // meaningful work. Check output to avoid prematurely marking as 'verified'. + const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let agentOutput = ''; + try { + agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string; + } catch { + /* */ + } + + // Determine if the agent did meaningful work by checking for tool usage + // indicators in the output. The agent executor writes "🔧 Tool:" markers + // each time a tool is invoked. No tool usage suggests the CLI exited + // without performing implementation work. + const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER); + const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH; + const agentDidWork = hasToolUsage && !isOutputTooShort; + + let finalStatus: 'verified' | 'waiting_approval'; + if (feature.skipTests) { + finalStatus = 'waiting_approval'; + } else if (!agentDidWork) { + // Agent didn't produce meaningful output (e.g., CLI exited quickly). + // Route to waiting_approval so the user can review and re-run. + finalStatus = 'waiting_approval'; + logger.warn( + `[executeFeature] Feature ${featureId}: agent produced insufficient output ` + + `(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` + + `Setting status to waiting_approval instead of verified.` + ); + } else { + finalStatus = 'verified'; + } + + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + this.recordSuccessFn(); + + // Check final task completion state for accurate reporting + const completedFeature = await this.loadFeatureFn(projectPath, featureId); + const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0; + const completedTasks = + completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0; + const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; + + try { + if (agentOutput) { + const summary = extractSummary(agentOutput); + if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary); + } + if (contextResult.memoryFiles.length > 0 && agentOutput) { + await recordMemoryUsage( + projectPath, + contextResult.memoryFiles, + agentOutput, + true, + secureFs as Parameters[4] + ); + } + await this.recordLearningsFn(projectPath, feature, agentOutput); + } catch { + /* learnings recording failed */ + } + + const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000); + let completionMessage = `Feature completed in ${elapsedSeconds}s`; + if (finalStatus === 'verified') completionMessage += ' - auto-verified'; + if (hasIncompleteTasks) + completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`; + + if (isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: completionMessage, + projectPath, + model: tempRunningFeature.model, + provider: tempRunningFeature.provider, + }); + } + } catch (error) { + const errorInfo = classifyError(error); + if (errorInfo.isAbort) { + await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); + if (isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: false, + message: 'Feature stopped by user', + projectPath, + }); + } + } else { + logger.error(`Feature ${featureId} failed:`, error); + await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) { + this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message }); + } + } + } finally { + this.releaseRunningFeature(featureId); + if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath); + } + } + + async stopFeature(featureId: string): Promise { + const running = this.concurrencyManager.getRunningFeature(featureId); + if (!running) return false; + const { projectPath } = running; + + // Immediately update feature status to 'interrupted' so the UI reflects + // the stop right away. CLI-based providers can take seconds to terminate + // their subprocess after the abort signal fires, leaving the feature stuck + // in 'in_progress' on the Kanban board until the executeFeature catch block + // eventually runs. By persisting and emitting the status change here, the + // board updates immediately regardless of how long the subprocess takes to stop. + try { + await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); + } catch (err) { + // Non-fatal: the abort still proceeds and executeFeature's catch block + // will attempt the same update once the subprocess terminates. + logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err); + } + + running.abortController.abort(); + this.releaseRunningFeature(featureId, { force: true }); + return true; + } +} diff --git a/apps/server/src/services/execution-types.ts b/apps/server/src/services/execution-types.ts new file mode 100644 index 00000000..8a98b243 --- /dev/null +++ b/apps/server/src/services/execution-types.ts @@ -0,0 +1,214 @@ +/** + * Execution Types - Type definitions for ExecutionService and related services + * + * Contains callback types used by ExecutionService for dependency injection, + * allowing the service to delegate to other services without circular dependencies. + */ + +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import type { loadContextFiles } from '@automaker/utils'; +import type { PipelineContext } from './pipeline-orchestrator.js'; + +// ============================================================================= +// ExecutionService Callback Types +// ============================================================================= + +/** + * Function to run the agent with a prompt + */ +export type RunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: { + projectPath?: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + branchName?: string | null; + } +) => Promise; + +/** + * Function to execute pipeline steps + */ +export type ExecutePipelineFn = (context: PipelineContext) => Promise; + +/** + * Function to update feature status + */ +export type UpdateFeatureStatusFn = ( + projectPath: string, + featureId: string, + status: string +) => Promise; + +/** + * Function to load a feature by ID + */ +export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise; + +/** + * Function to get the planning prompt prefix based on feature's planning mode + */ +export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise; + +/** + * Function to save a feature summary + */ +export type SaveFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +/** + * Function to record learnings from a completed feature + */ +export type RecordLearningsFn = ( + projectPath: string, + feature: Feature, + agentOutput: string +) => Promise; + +/** + * Function to check if context exists for a feature + */ +export type ContextExistsFn = (projectPath: string, featureId: string) => Promise; + +/** + * Function to resume a feature (continues from saved context or starts fresh) + */ +export type ResumeFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + _calledInternally: boolean +) => Promise; + +/** + * Function to track failure and check if pause threshold is reached + * Returns true if auto-mode should pause + */ +export type TrackFailureFn = (errorInfo: { type: string; message: string }) => boolean; + +/** + * Function to signal that auto-mode should pause due to failures + */ +export type SignalPauseFn = (errorInfo: { type: string; message: string }) => void; + +/** + * Function to record a successful execution (resets failure tracking) + */ +export type RecordSuccessFn = () => void; + +/** + * Function to save execution state + */ +export type SaveExecutionStateFn = (projectPath: string) => Promise; + +/** + * Type alias for loadContextFiles function + */ +export type LoadContextFilesFn = typeof loadContextFiles; + +// ============================================================================= +// PipelineOrchestrator Callback Types +// ============================================================================= + +/** + * Function to build feature prompt + */ +export type BuildFeaturePromptFn = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } +) => string; + +/** + * Function to execute a feature + */ +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } +) => Promise; + +/** + * Function to run agent (for PipelineOrchestrator) + */ +export type PipelineRunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: Record +) => Promise; + +// ============================================================================= +// AutoLoopCoordinator Callback Types +// ============================================================================= + +/** + * Function to execute a feature in auto-loop + */ +export type AutoLoopExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean +) => Promise; + +/** + * Function to load pending features for a worktree + */ +export type LoadPendingFeaturesFn = ( + projectPath: string, + branchName: string | null +) => Promise; + +/** + * Function to save execution state for auto-loop + */ +export type AutoLoopSaveExecutionStateFn = ( + projectPath: string, + branchName: string | null, + maxConcurrency: number +) => Promise; + +/** + * Function to clear execution state + */ +export type ClearExecutionStateFn = ( + projectPath: string, + branchName: string | null +) => Promise; + +/** + * Function to reset stuck features + */ +export type ResetStuckFeaturesFn = (projectPath: string) => Promise; + +/** + * Function to check if a feature is finished + */ +export type IsFeatureFinishedFn = (feature: Feature) => boolean; + +/** + * Function to check if a feature is running + */ +export type IsFeatureRunningFn = (featureId: string) => boolean; diff --git a/apps/server/src/services/feature-export-service.ts b/apps/server/src/services/feature-export-service.ts index a58b6527..bd741dc2 100644 --- a/apps/server/src/services/feature-export-service.ts +++ b/apps/server/src/services/feature-export-service.ts @@ -205,7 +205,6 @@ export class FeatureExportService { importData: FeatureImport ): Promise { const warnings: string[] = []; - const errors: string[] = []; try { // Extract feature from data (handle both raw Feature and wrapped FeatureExport) diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index b40a85f0..5b21e44b 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -195,9 +195,10 @@ export class FeatureLoader { } // Read all feature directories + // secureFs.readdir returns Dirent[] but typed as generic; cast to access isDirectory() const entries = (await secureFs.readdir(featuresDir, { withFileTypes: true, - })) as any[]; + })) as import('fs').Dirent[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); // Load all features concurrently with automatic recovery from backups @@ -224,6 +225,14 @@ export class FeatureLoader { return null; } + // Clear transient runtime flag - titleGenerating is only meaningful during + // the current session's async title generation. If it was persisted (e.g., + // app closed before generation completed), it would cause the UI to show + // "Generating title..." indefinitely. + if (feature.titleGenerating) { + delete feature.titleGenerating; + } + return feature; }); @@ -322,7 +331,14 @@ export class FeatureLoader { logRecoveryWarning(result, `Feature ${featureId}`, logger); - return result.data; + const feature = result.data; + + // Clear transient runtime flag (same as in getAll) + if (feature?.titleGenerating) { + delete feature.titleGenerating; + } + + return feature; } /** @@ -366,8 +382,15 @@ export class FeatureLoader { descriptionHistory: initialHistory, }; + // Remove transient runtime fields before persisting to disk. + // titleGenerating is UI-only state that tracks in-flight async title generation. + // Persisting it can cause cards to show "Generating title..." indefinitely + // if the app restarts before generation completes. + const featureToWrite = { ...feature }; + delete featureToWrite.titleGenerating; + // Write feature.json atomically with backup support - await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); logger.info(`Created feature ${featureId}`); return feature; @@ -451,9 +474,13 @@ export class FeatureLoader { descriptionHistory: updatedHistory, }; + // Remove transient runtime fields before persisting (same as create) + const featureToWrite = { ...updatedFeature }; + delete featureToWrite.titleGenerating; + // Write back to file atomically with backup support const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); - await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT }); + await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); logger.info(`Updated feature ${featureId}`); return updatedFeature; diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts new file mode 100644 index 00000000..1f8a4952 --- /dev/null +++ b/apps/server/src/services/feature-state-manager.ts @@ -0,0 +1,644 @@ +/** + * FeatureStateManager - Manages feature status updates with proper persistence + * + * Extracted from AutoModeService to provide a standalone service for: + * - Updating feature status with proper disk persistence + * - Handling corrupted JSON with backup recovery + * - Emitting events AFTER successful persistence (prevent stale data on refresh) + * - Resetting stuck features after server restart + * + * Key behaviors: + * - Persist BEFORE emit (Pitfall 2 from research) + * - Use readJsonWithRecovery for all reads + * - markInterrupted preserves pipeline_* statuses + */ + +import path from 'path'; +import type { Feature, ParsedTask, PlanSpec } from '@automaker/types'; +import { + atomicWriteJson, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, + createLogger, +} from '@automaker/utils'; +import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { AutoModeEventType } from './typed-event-bus.js'; +import { getNotificationService } from './notification-service.js'; +import { FeatureLoader } from './feature-loader.js'; + +const logger = createLogger('FeatureStateManager'); + +/** + * FeatureStateManager handles feature status updates with persistence guarantees. + * + * This service is responsible for: + * 1. Updating feature status and persisting to disk BEFORE emitting events + * 2. Handling corrupted JSON with automatic backup recovery + * 3. Resetting stuck features after server restarts + * 4. Managing justFinishedAt timestamps for UI badges + */ +export class FeatureStateManager { + private events: EventEmitter; + private featureLoader: FeatureLoader; + + constructor(events: EventEmitter, featureLoader: FeatureLoader) { + this.events = events; + this.featureLoader = featureLoader; + } + + /** + * Load a feature from disk with recovery support + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to load + * @returns The feature data, or null if not found/recoverable + */ + async loadFeature(projectPath: string, featureId: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + logRecoveryWarning(result, `Feature ${featureId}`, logger); + return result.data; + } catch { + return null; + } + } + + /** + * Update feature status with proper persistence and event ordering. + * + * IMPORTANT: Persists to disk BEFORE emitting events to prevent stale data + * on client refresh (Pitfall 2 from research). + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to update + * @param status - New status value + */ + async updateFeatureStatus(projectPath: string, featureId: string, status: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + feature.status = status; + feature.updatedAt = new Date().toISOString(); + + // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) + // Badge will show for 2 minutes after this timestamp + if (status === 'waiting_approval') { + feature.justFinishedAt = new Date().toISOString(); + + // Finalize task statuses when feature is done: + // - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them) + // - Do NOT mark pending tasks as completed (they were never started) + // - Clear currentTaskId since no task is actively running + // This prevents cards in "waiting for review" from appearing to still have running tasks + if (feature.planSpec?.tasks) { + let tasksFinalized = 0; + let tasksPending = 0; + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; + } + } + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval` + ); + } + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } + // Update tasksCompleted count to reflect actual completed tasks + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + } + } else if (status === 'verified') { + // Also finalize in_progress tasks when moving directly to verified (skipTests=false) + // Do NOT mark pending tasks as completed - they were never started + if (feature.planSpec?.tasks) { + let tasksFinalized = 0; + let tasksPending = 0; + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; + } + } + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified` + ); + } + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + } + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; + } else { + // Clear the timestamp when moving to other statuses + feature.justFinishedAt = undefined; + } + + // PERSIST BEFORE EMIT (Pitfall 2) + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit status change event so UI can react without polling + this.emitAutoModeEvent('feature_status_changed', { + featureId, + projectPath, + status, + }); + + // Create notifications for important status changes + // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below + try { + const notificationService = getNotificationService(); + if (status === 'waiting_approval') { + await notificationService.createNotification({ + type: 'feature_waiting_approval', + title: 'Feature Ready for Review', + message: `"${feature.name || featureId}" is ready for your review and approval.`, + featureId, + projectPath, + }); + } else if (status === 'verified') { + await notificationService.createNotification({ + type: 'feature_verified', + title: 'Feature Verified', + message: `"${feature.name || featureId}" has been verified and is complete.`, + featureId, + projectPath, + }); + } + } catch (notificationError) { + logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError); + } + + // Sync completed/verified features to app_spec.txt + if (status === 'verified' || status === 'completed') { + try { + await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); + } catch (syncError) { + // Log but don't fail the status update if sync fails + logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); + } + } + } catch (error) { + logger.error(`Failed to update feature status for ${featureId}:`, error); + } + } + + /** + * Mark a feature as interrupted due to server restart or other interruption. + * + * This is a convenience helper that updates the feature status to 'interrupted', + * indicating the feature was in progress but execution was disrupted (e.g., server + * restart, process crash, or manual stop). Features with this status can be + * resumed later using the resume functionality. + * + * Note: Features with pipeline_* statuses are preserved rather than overwritten + * to 'interrupted'. This ensures that resumePipelineFeature() can pick up from + * the correct pipeline step after a restart. + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to mark as interrupted + * @param reason - Optional reason for the interruption (logged for debugging) + */ + async markFeatureInterrupted( + projectPath: string, + featureId: string, + reason?: string + ): Promise { + // Load the feature to check its current status + const feature = await this.loadFeature(projectPath, featureId); + const currentStatus = feature?.status; + + // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step + if (currentStatus && currentStatus.startsWith('pipeline_')) { + logger.info( + `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` + ); + return; + } + + if (reason) { + logger.info(`Marking feature ${featureId} as interrupted: ${reason}`); + } else { + logger.info(`Marking feature ${featureId} as interrupted`); + } + + await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); + } + + /** + * Shared helper that scans features in a project directory and resets any stuck + * in transient states (in_progress, interrupted, pipeline_*) back to resting states. + * + * Also resets: + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * @param projectPath - The project path to scan + * @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates') + * @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count + */ + private async scanAndResetFeatures( + projectPath: string, + callerLabel: string + ): Promise<{ + reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }>; + reconciledFeatureIds: string[]; + reconciledCount: number; + scanned: number; + }> { + const featuresDir = getFeaturesDir(projectPath); + let scanned = 0; + let reconciledCount = 0; + const reconciledFeatureIds: string[] = []; + const reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }> = []; + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + scanned++; + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) continue; + + let needsUpdate = false; + const originalStatus = feature.status; + + // Reset features in active execution states back to a resting state + // After a server restart, no processes are actually running + const isActiveState = + originalStatus === 'in_progress' || + originalStatus === 'interrupted' || + (originalStatus != null && originalStatus.startsWith('pipeline_')); + + if (isActiveState) { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` + ); + } + + // Reset generating planSpec status back to pending (spec generation was interrupted) + if (feature.planSpec?.status === 'generating') { + feature.planSpec.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending` + ); + } + + // Reset any in_progress tasks back to pending (task execution was interrupted) + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + ); + // Clear currentTaskId if it points to this reverted task + if (feature.planSpec?.currentTaskId === task.id) { + feature.planSpec.currentTaskId = undefined; + logger.info( + `[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` + ); + } + } + } + } + + if (needsUpdate) { + feature.updatedAt = new Date().toISOString(); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + reconciledCount++; + reconciledFeatureIds.push(feature.id); + reconciledFeatures.push({ + id: feature.id, + previousStatus: originalStatus, + newStatus: feature.status, + }); + } + } + } catch (error) { + // If features directory doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error); + } + } + + return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned }; + } + + /** + * Reset features that were stuck in transient states due to server crash. + * Called when auto mode is enabled to clean up from previous session. + * + * Resets: + * - in_progress features back to ready (if has plan) or backlog (if no plan) + * - interrupted features back to ready (if has plan) or backlog (if no plan) + * - pipeline_* features back to ready (if has plan) or backlog (if no plan) + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * @param projectPath - The project path to reset features for + */ + async resetStuckFeatures(projectPath: string): Promise { + const { reconciledCount, scanned } = await this.scanAndResetFeatures( + projectPath, + 'resetStuckFeatures' + ); + + logger.info( + `[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}` + ); + } + + /** + * Reconcile all feature states on server startup. + * + * This method resets all features stuck in transient states (in_progress, + * interrupted, pipeline_*) and emits events so connected UI clients + * immediately reflect the corrected states. + * + * Should be called once during server initialization, before the UI is served, + * to ensure feature state consistency after any type of restart (clean, forced, crash). + * + * @param projectPath - The project path to reconcile features for + * @returns The number of features that were reconciled + */ + async reconcileAllFeatureStates(projectPath: string): Promise { + logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); + + const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } = + await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates'); + + // Emit per-feature status change events so UI invalidates its cache + for (const { id, previousStatus, newStatus } of reconciledFeatures) { + this.emitAutoModeEvent('feature_status_changed', { + featureId: id, + projectPath, + status: newStatus, + previousStatus, + reason: 'server_restart_reconciliation', + }); + } + + // Emit a bulk reconciliation event for the UI + if (reconciledCount > 0) { + this.emitAutoModeEvent('features_reconciled', { + projectPath, + reconciledCount, + reconciledFeatureIds, + message: `Reconciled ${reconciledCount} feature(s) after server restart`, + }); + } + + logger.info( + `[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}` + ); + + return reconciledCount; + } + + /** + * Update the planSpec of a feature with partial updates. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param updates - Partial PlanSpec updates to apply + */ + async updateFeaturePlanSpec( + projectPath: string, + featureId: string, + updates: Partial + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + // Initialize planSpec if it doesn't exist + if (!feature.planSpec) { + feature.planSpec = { + status: 'pending', + version: 1, + reviewedByUser: false, + }; + } + + // Capture old content BEFORE applying updates for version comparison + const oldContent = feature.planSpec.content; + + // Apply updates + Object.assign(feature.planSpec, updates); + + // If content is being updated and it's different from old content, increment version + if (updates.content !== undefined && updates.content !== oldContent) { + feature.planSpec.version = (feature.planSpec.version || 0) + 1; + } + + feature.updatedAt = new Date().toISOString(); + + // PERSIST BEFORE EMIT + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('plan_spec_updated', { + featureId, + projectPath, + planSpec: feature.planSpec, + }); + } catch (error) { + logger.error(`Failed to update planSpec for ${featureId}:`, error); + } + } + + /** + * Save the extracted summary to a feature's summary field. + * This is called after agent execution completes to save a summary + * extracted from the agent's output using tags. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param summary - The summary text to save + */ + async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + feature.summary = summary; + feature.updatedAt = new Date().toISOString(); + + // PERSIST BEFORE EMIT + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('auto_mode_summary', { + featureId, + projectPath, + summary, + }); + } catch (error) { + logger.error(`Failed to save summary for ${featureId}:`, error); + } + } + + /** + * Update the status of a specific task within planSpec.tasks + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param taskId - The task ID to update + * @param status - The new task status + */ + async updateTaskStatus( + projectPath: string, + featureId: string, + taskId: string, + status: ParsedTask['status'] + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature || !feature.planSpec?.tasks) { + logger.warn(`Feature ${featureId} not found or has no tasks`); + return; + } + + // Find and update the task + const task = feature.planSpec.tasks.find((t) => t.id === taskId); + if (task) { + task.status = status; + feature.updatedAt = new Date().toISOString(); + + // PERSIST BEFORE EMIT + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('auto_mode_task_status', { + featureId, + projectPath, + taskId, + status, + tasks: feature.planSpec.tasks, + }); + } else { + const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', '); + logger.warn( + `[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]` + ); + } + } catch (error) { + logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); + } + } + + /** + * Emit an auto-mode event via the event emitter + * + * @param eventType - The event type (e.g., 'auto_mode_summary') + * @param data - The event payload + */ + private emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit('auto-mode:event', { + type: eventType, + ...data, + }); + } +} diff --git a/apps/server/src/services/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 00000000..fba8bda3 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,817 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + +/** TTL for cached credentials in milliseconds (5 minutes) */ +const CREDENTIALS_CACHE_TTL_MS = 5 * 60 * 1000; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedCredentialsAt: number | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + /** The actual path from which credentials were loaded (for write-back) */ + private loadedCredentialsPath: string | null = null; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file. + * Implements TTL-based cache invalidation and file mtime checks. + */ + private async loadCredentials(): Promise { + // Check if cached credentials are still valid + if (this.cachedCredentials && this.cachedCredentialsAt) { + const now = Date.now(); + const cacheAge = now - this.cachedCredentialsAt; + + if (cacheAge < CREDENTIALS_CACHE_TTL_MS) { + // Cache is within TTL - also check file mtime + const sourcePath = this.loadedCredentialsPath || this.credentialsPath; + try { + const stat = fs.statSync(sourcePath); + if (stat.mtimeMs <= this.cachedCredentialsAt) { + // File hasn't been modified since we cached - use cache + return this.cachedCredentials; + } + // File has been modified, fall through to re-read + logger.debug('[loadCredentials] File modified since cache, re-reading'); + } catch { + // File doesn't exist or can't stat - use cache + return this.cachedCredentials; + } + } else { + // Cache TTL expired, discard + logger.debug('[loadCredentials] Cache TTL expired, re-reading'); + } + + // Invalidate cached credentials + this.cachedCredentials = null; + this.cachedCredentialsAt = null; + } + + // Build unique possible paths (deduplicate) + const rawPaths = [ + this.credentialsPath, + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + const possiblePaths = [...new Set(rawPaths)]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + // Try 'which' on Unix-like systems, 'where' on Windows + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + try { + const whichResult = execFileSync(whichCmd, ['gemini'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + // 'where' on Windows may return multiple lines; take the first + const firstLine = whichResult.split('\n')[0]?.trim(); + if (firstLine && fs.existsSync(firstLine)) { + return firstLine; + } + } catch { + // Ignore errors from 'which'/'where' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules (POSIX only) + if (process.platform !== 'win32') { + try { + const searchBase = path.resolve(baseDir, '..'); + const searchResult = execFileSync( + 'find', + [searchBase, '-name', 'oauth2.js', '-path', '*gemini*', '-path', '*code_assist*'], + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] } + ) + .trim() + .split('\n')[0]; // Take first result + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + this.cachedCredentialsAt = Date.now(); + + // Save back to the file the credentials were loaded from + const writePath = this.loadedCredentialsPath || this.credentialsPath; + try { + fs.writeFileSync(writePath, JSON.stringify(this.cachedCredentials, null, 2)); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedCredentialsAt = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/apps/server/src/services/github-pr-comment.service.ts b/apps/server/src/services/github-pr-comment.service.ts new file mode 100644 index 00000000..b49d417a --- /dev/null +++ b/apps/server/src/services/github-pr-comment.service.ts @@ -0,0 +1,103 @@ +/** + * GitHub PR Comment Service + * + * Domain logic for resolving/unresolving PR review threads via the + * GitHub GraphQL API. Extracted from the route handler so the route + * only deals with request/response plumbing. + */ + +import { spawn } from 'child_process'; +import { execEnv } from '../lib/exec-utils.js'; + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +interface GraphQLMutationResponse { + data?: { + resolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + unresolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + }; + errors?: Array<{ message: string }>; +} + +/** + * Execute a GraphQL mutation to resolve or unresolve a review thread. + */ +export async function executeReviewThreadMutation( + projectPath: string, + threadId: string, + resolve: boolean +): Promise<{ isResolved: boolean }> { + const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'; + + const mutation = ` + mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) { + ${mutationName}(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } + }`; + + const variables = { threadId }; + const requestBody = JSON.stringify({ query: mutation, variables }); + + // Declare timeoutId before registering the error handler to avoid TDZ confusion + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((res, rej) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + rej(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + rej(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return rej(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + res(JSON.parse(stdout)); + } catch (e) { + rej(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + const threadData = resolve + ? response.data?.resolveReviewThread?.thread + : response.data?.unresolveReviewThread?.thread; + + if (!threadData) { + throw new Error('No thread data returned from GitHub API'); + } + + return { isResolved: threadData.isResolved }; +} diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 62edeaae..9bbea03b 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -27,7 +27,6 @@ import type { } from '@automaker/types'; import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; import { - getIdeationDir, getIdeasDir, getIdeaDir, getIdeaPath, @@ -230,10 +229,9 @@ export class IdeationService { ); if (providerResult.provider) { claudeCompatibleProvider = providerResult.provider; - // Use resolved model from provider if available (maps to Claude model) - if (providerResult.resolvedModel) { - modelId = providerResult.resolvedModel; - } + // CRITICAL: For custom providers, use the provider's model ID (e.g. "GLM-4.7") + // for the API call, NOT the resolved Claude model - otherwise we get "model not found" + modelId = options.model; credentials = providerResult.credentials ?? credentials; } } @@ -408,7 +406,9 @@ export class IdeationService { return []; } - const entries = (await secureFs.readdir(ideasDir, { withFileTypes: true })) as any[]; + const entries = (await secureFs.readdir(ideasDir, { + withFileTypes: true, + })) as import('fs').Dirent[]; const ideaDirs = entries.filter((entry) => entry.isDirectory()); const ideas: Idea[] = []; @@ -856,15 +856,26 @@ ${contextSection}${existingWorkSection}`; } return parsed - .map((item: any, index: number) => ({ - id: this.generateId('sug'), - category, - title: item.title || `Suggestion ${index + 1}`, - description: item.description || '', - rationale: item.rationale || '', - priority: item.priority || 'medium', - relatedFiles: item.relatedFiles || [], - })) + .map( + ( + item: { + title?: string; + description?: string; + rationale?: string; + priority?: 'low' | 'medium' | 'high'; + relatedFiles?: string[]; + }, + index: number + ) => ({ + id: this.generateId('sug'), + category, + title: item.title || `Suggestion ${index + 1}`, + description: item.description || '', + rationale: item.rationale || '', + priority: item.priority || ('medium' as const), + relatedFiles: item.relatedFiles || [], + }) + ) .slice(0, count); } catch (error) { logger.warn('Failed to parse JSON response:', error); @@ -889,7 +900,7 @@ ${contextSection}${existingWorkSection}`; for (const line of lines) { // Check for numbered items or markdown headers - const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/); + const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/); if (titleMatch) { // Save previous suggestion @@ -1706,7 +1717,9 @@ ${contextSection}${existingWorkSection}`; const results: AnalysisFileInfo[] = []; try { - const entries = (await secureFs.readdir(dirPath, { withFileTypes: true })) as any[]; + const entries = (await secureFs.readdir(dirPath, { + withFileTypes: true, + })) as import('fs').Dirent[]; for (const entry of entries) { if (entry.isDirectory()) { diff --git a/apps/server/src/services/merge-service.ts b/apps/server/src/services/merge-service.ts new file mode 100644 index 00000000..0301d4a8 --- /dev/null +++ b/apps/server/src/services/merge-service.ts @@ -0,0 +1,299 @@ +/** + * MergeService - Direct merge operations without HTTP + * + * Extracted from worktree merge route to allow internal service calls. + */ + +import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils'; +import { type EventEmitter } from '../lib/events.js'; +import { execGitCommand } from '@automaker/git-utils'; +const logger = createLogger('MergeService'); + +export interface MergeOptions { + squash?: boolean; + message?: string; + deleteWorktreeAndBranch?: boolean; + /** Remote name to fetch from before merging (defaults to 'origin') */ + remote?: string; +} + +export interface MergeServiceResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + conflictFiles?: string[]; + mergedBranch?: string; + targetBranch?: string; + deleted?: { + worktreeDeleted: boolean; + branchDeleted: boolean; + }; +} + +/** + * Perform a git merge operation directly without HTTP. + * + * @param projectPath - Path to the git repository + * @param branchName - Source branch to merge + * @param worktreePath - Path to the worktree (used for deletion if requested) + * @param targetBranch - Branch to merge into (defaults to 'main') + * @param options - Merge options + * @param options.squash - If true, perform a squash merge + * @param options.message - Custom merge commit message + * @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge + * @param options.remote - Remote name to fetch from before merging (defaults to 'origin') + */ +export async function performMerge( + projectPath: string, + branchName: string, + worktreePath: string, + targetBranch: string = 'main', + options?: MergeOptions, + emitter?: EventEmitter +): Promise { + if (!projectPath || !branchName || !worktreePath) { + return { + success: false, + error: 'projectPath, branchName, and worktreePath are required', + }; + } + + const mergeTo = targetBranch || 'main'; + + // Validate branch names early to reject invalid input before any git operations + if (!isValidBranchName(branchName)) { + return { + success: false, + error: `Invalid source branch name: "${branchName}"`, + }; + } + if (!isValidBranchName(mergeTo)) { + return { + success: false, + error: `Invalid target branch name: "${mergeTo}"`, + }; + } + + // Validate source branch exists (using safe array-based command) + try { + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); + } catch { + return { + success: false, + error: `Branch "${branchName}" does not exist`, + }; + } + + // Validate target branch exists (using safe array-based command) + try { + await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath); + } catch { + return { + success: false, + error: `Target branch "${mergeTo}" does not exist`, + }; + } + + // Validate the remote name to prevent git option injection. + // Reject invalid remote names so the caller knows their input was wrong, + // consistent with how invalid branch names are handled above. + const remote = options?.remote || 'origin'; + if (!isValidRemoteName(remote)) { + logger.warn('Invalid remote name supplied to merge-service', { + remote, + projectPath, + }); + return { + success: false, + error: `Invalid remote name: "${remote}"`, + }; + } + + // Fetch latest from remote before merging to ensure we have up-to-date refs + try { + await execGitCommand(['fetch', remote], projectPath); + } catch (fetchError) { + logger.warn('Failed to fetch from remote before merge; proceeding with local refs', { + remote, + projectPath, + error: (fetchError as Error).message, + }); + // Non-fatal: proceed with local refs if fetch fails (e.g. offline) + } + + // Emit merge:start after validating inputs + emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath }); + + // Merge the feature branch into the target branch (using safe array-based commands) + const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`; + const mergeArgs = options?.squash + ? ['merge', '--squash', branchName] + : ['merge', branchName, '-m', mergeMessage]; + + try { + // Set LC_ALL=C so git always emits English output regardless of the system + // locale, making text-based conflict detection reliable. + await execGitCommand(mergeArgs, projectPath, { LC_ALL: 'C' }); + } catch (mergeError: unknown) { + // Check if this is a merge conflict. We use a multi-layer strategy so + // that detection is reliable even when locale settings vary or git's text + // output changes across versions: + // + // 1. Primary (text-based): scan the error output for well-known English + // conflict markers. Because we pass LC_ALL=C above these strings are + // always in English, but we keep the check as one layer among several. + // + // 2. Unmerged-path check: run `git diff --name-only --diff-filter=U` + // (locale-stable) and treat any non-empty output as a conflict + // indicator, capturing the file list at the same time. + // + // 3. Fallback status check: run `git status --porcelain` and look for + // lines whose first two characters indicate an unmerged state + // (UU, AA, DD, AU, UA, DU, UD). + // + // hasConflicts is true when ANY of the three layers returns positive. + const err = mergeError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + + // Layer 1 – text matching (locale-safe because we set LC_ALL=C above). + const textIndicatesConflict = + output.includes('CONFLICT') || output.includes('Automatic merge failed'); + + // Layers 2 & 3 – repository state inspection (locale-independent). + // Layer 2: get conflicted files via diff (also locale-stable output). + let conflictFiles: string[] | undefined; + let diffIndicatesConflict = false; + try { + const diffOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + projectPath, + { LC_ALL: 'C' } + ); + const files = diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + if (files.length > 0) { + diffIndicatesConflict = true; + conflictFiles = files; + } + } catch { + // If we can't get the file list, leave conflictFiles undefined so callers + // can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined) + } + + // Layer 3: check for unmerged paths via machine-readable git status. + let hasUnmergedPaths = false; + try { + const statusOutput = await execGitCommand(['status', '--porcelain'], projectPath, { + LC_ALL: 'C', + }); + // Unmerged status codes occupy the first two characters of each line. + // Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD. + const unmergedLines = statusOutput + .split('\n') + .filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + hasUnmergedPaths = unmergedLines.length > 0; + + // If Layer 2 did not populate conflictFiles (e.g. diff failed or returned + // nothing) but Layer 3 does detect unmerged paths, parse the status lines + // to extract filenames and assign them to conflictFiles so callers always + // receive an accurate file list when conflicts are present. + if (hasUnmergedPaths && conflictFiles === undefined) { + const parsedFiles = unmergedLines + .map((line) => line.slice(2).trim()) + .filter((f) => f.length > 0); + // Deduplicate (e.g. rename entries can appear twice) + conflictFiles = [...new Set(parsedFiles)]; + } + } catch { + // git status failing is itself a sign something is wrong; leave + // hasUnmergedPaths as false and rely on the other layers. + } + + const hasConflicts = textIndicatesConflict || diffIndicatesConflict || hasUnmergedPaths; + + if (hasConflicts) { + // Emit merge:conflict event with conflict details + emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles }); + + return { + success: false, + error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + hasConflicts: true, + conflictFiles, + }; + } + + // Emit merge:error for non-conflict errors before re-throwing + emitter?.emit('merge:error', { + branchName, + targetBranch: mergeTo, + error: err.message || String(mergeError), + }); + + // Re-throw non-conflict errors + throw mergeError; + } + + // If squash merge, need to commit (using safe array-based command) + if (options?.squash) { + const squashMessage = options?.message || `Merge ${branchName} (squash)`; + try { + await execGitCommand(['commit', '-m', squashMessage], projectPath); + } catch (commitError: unknown) { + const err = commitError as { message?: string }; + // Emit merge:error so subscribers always receive either merge:success or merge:error + emitter?.emit('merge:error', { + branchName, + targetBranch: mergeTo, + error: err.message || String(commitError), + }); + throw commitError; + } + } + + // Optionally delete the worktree and branch after merging + let worktreeDeleted = false; + let branchDeleted = false; + + if (options?.deleteWorktreeAndBranch) { + // Remove the worktree + try { + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); + worktreeDeleted = true; + } catch { + // Try with prune if remove fails + try { + await execGitCommand(['worktree', 'prune'], projectPath); + worktreeDeleted = true; + } catch { + logger.warn(`Failed to remove worktree: ${worktreePath}`); + } + } + + // Delete the branch (but not main/master) + if (branchName !== 'main' && branchName !== 'master') { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + logger.warn(`Failed to delete branch: ${branchName}`); + } + } + } + + // Emit merge:success with merged branch, target branch, and deletion info + emitter?.emit('merge:success', { + mergedBranch: branchName, + targetBranch: mergeTo, + deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + }); + + return { + success: true, + mergedBranch: branchName, + targetBranch: mergeTo, + deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + }; +} diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts new file mode 100644 index 00000000..c8564b18 --- /dev/null +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -0,0 +1,659 @@ +/** + * PipelineOrchestrator - Pipeline step execution and coordination + */ + +import path from 'path'; +import type { + Feature, + PipelineStep, + PipelineConfig, + FeatureStatusWithPipeline, +} from '@automaker/types'; +import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; +import { validateWorkingDirectory } from '../lib/sdk-options.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { FeatureStateManager } from './feature-state-manager.js'; +import type { AgentExecutor } from './agent-executor.js'; +import type { WorktreeResolver } from './worktree-resolver.js'; +import type { SettingsService } from './settings-service.js'; +import type { ConcurrencyManager } from './concurrency-manager.js'; +import { pipelineService } from './pipeline-service.js'; +import type { TestRunnerService, TestRunStatus } from './test-runner-service.js'; +import { performMerge } from './merge-service.js'; +import type { + PipelineContext, + PipelineStatusInfo, + StepResult, + MergeResult, + UpdateFeatureStatusFn, + BuildFeaturePromptFn, + ExecuteFeatureFn, + RunAgentFn, +} from './pipeline-types.js'; + +// Re-export types for backward compatibility +export type { + PipelineContext, + PipelineStatusInfo, + StepResult, + MergeResult, + UpdateFeatureStatusFn, + BuildFeaturePromptFn, + ExecuteFeatureFn, + RunAgentFn, +} from './pipeline-types.js'; + +const logger = createLogger('PipelineOrchestrator'); + +export class PipelineOrchestrator { + constructor( + private eventBus: TypedEventBus, + private featureStateManager: FeatureStateManager, + private agentExecutor: AgentExecutor, + private testRunnerService: TestRunnerService, + private worktreeResolver: WorktreeResolver, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private updateFeatureStatusFn: UpdateFeatureStatusFn, + private loadContextFilesFn: typeof loadContextFiles, + private buildFeaturePromptFn: BuildFeaturePromptFn, + private executeFeatureFn: ExecuteFeatureFn, + private runAgentFn: RunAgentFn + ) {} + + async executePipeline(ctx: PipelineContext): Promise { + const { + projectPath, + featureId, + feature, + steps, + workDir, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + } = ctx; + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const contextResult = await this.loadContextFilesFn({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + taskContext: { title: feature.title ?? '', description: feature.description ?? '' }, + }); + const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + /* */ + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (abortController.signal.aborted) throw new Error('Pipeline execution aborted'); + await this.updateFeatureStatusFn(projectPath, featureId, `pipeline_${step.id}`); + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName: feature.branchName ?? null, + content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, + projectPath, + }); + this.eventBus.emitAutoModeEvent('pipeline_step_started', { + featureId, + stepId: step.id, + stepName: step.name, + stepIndex: i, + totalSteps: steps.length, + projectPath, + }); + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + await this.runAgentFn( + workDir, + featureId, + this.buildPipelineStepPrompt(step, feature, previousContext, prompts.taskExecution), + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + previousContent: previousContext, + systemPrompt: contextFilesPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + } + ); + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + /* */ + } + this.eventBus.emitAutoModeEvent('pipeline_step_complete', { + featureId, + stepId: step.id, + stepName: step.name, + stepIndex: i, + totalSteps: steps.length, + projectPath, + }); + } + if (ctx.branchName) { + const mergeResult = await this.attemptMerge(ctx); + if (!mergeResult.success && mergeResult.hasConflicts) return; + } + } + + buildPipelineStepPrompt( + step: PipelineStep, + feature: Feature, + previousContext: string, + taskPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string } + ): string { + let prompt = `## Pipeline Step: ${step.name}\n\nThis is an automated pipeline step.\n\n### Feature Context\n${this.buildFeaturePromptFn(feature, taskPrompts)}\n\n`; + if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`; + return ( + prompt + + `### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.` + ); + } + + async detectPipelineStatus( + projectPath: string, + featureId: string, + currentStatus: FeatureStatusWithPipeline + ): Promise { + const isPipeline = pipelineService.isPipelineStatus(currentStatus); + if (!isPipeline) + return { + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + const stepId = pipelineService.getStepIdFromStatus(currentStatus); + if (!stepId) + return { + isPipeline: true, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + const config = await pipelineService.getPipelineConfig(projectPath); + if (!config || config.steps.length === 0) + return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null }; + const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + return { + isPipeline: true, + stepId, + stepIndex, + totalSteps: sortedSteps.length, + step: stepIndex === -1 ? null : sortedSteps[stepIndex], + config, + }; + } + + async resumePipeline( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo + ): Promise { + const featureId = feature.id; + const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + /* No context */ + } + + if (!hasContext) { + logger.warn(`No context for feature ${featureId}, restarting pipeline`); + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); + } + + if (pipelineInfo.stepIndex === -1) { + logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`); + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForStep?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline step no longer exists', + projectPath, + }); + } + return; + } + + if (!pipelineInfo.config) throw new Error('Pipeline config is null but stepIndex is valid'); + return this.resumeFromStep( + projectPath, + feature, + useWorktrees, + pipelineInfo.stepIndex, + pipelineInfo.config + ); + } + + /** Resume from a specific step index */ + async resumeFromStep( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + startFromStepIndex: number, + pipelineConfig: PipelineConfig + ): Promise { + const featureId = feature.id; + const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) + throw new Error(`Invalid step index: ${startFromStepIndex}`); + + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + let currentStep = allSortedSteps[startFromStepIndex]; + + if (excludedStepIds.has(currentStep.id)) { + const nextStatus = pipelineService.getNextStatus( + `pipeline_${currentStep.id}`, + pipelineConfig, + feature.skipTests ?? false, + feature.excludedPipelineSteps + ); + if (!pipelineService.isPipelineStatus(nextStatus)) { + await this.updateFeatureStatusFn(projectPath, featureId, nextStatus); + const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForExcluded?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed (remaining steps excluded)', + projectPath, + }); + } + return; + } + const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); + const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId); + if (nextStepIndex === -1) throw new Error(`Next step ${nextStepId} not found`); + startFromStepIndex = nextStepIndex; + } + + const stepsToExecute = allSortedSteps + .slice(startFromStepIndex) + .filter((step) => !excludedStepIds.has(step.id)); + if (stepsToExecute.length === 0) { + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForAllExcluded?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed (all steps excluded)', + projectPath, + }); + } + return; + } + + const runningEntry = this.concurrencyManager.acquire({ + featureId, + projectPath, + isAutoMode: false, + allowReuse: true, + }); + const abortController = runningEntry.abortController; + runningEntry.branchName = feature.branchName ?? null; + + try { + validateWorkingDirectory(projectPath); + let worktreePath: string | null = null; + const branchName = feature.branchName; + + if (useWorktrees && branchName) { + worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); + if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + runningEntry.worktreePath = worktreePath; + runningEntry.branchName = branchName ?? null; + + this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Resuming Pipeline', + description: feature.description, + }, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + const context: PipelineContext = { + projectPath, + featureId, + feature, + steps: stepsToExecute, + workDir, + worktreePath, + branchName: branchName ?? null, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await this.executePipeline(context); + + // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) + const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId); + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + + // Only update status if not already in a terminal state + if (reloadedFeature && reloadedFeature.status !== 'merge_conflict') { + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + } + logger.info(`Pipeline resume completed for feature ${featureId}`); + if (runningEntry.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline resumed successfully', + projectPath, + }); + } + } catch (error) { + const errorInfo = classifyError(error); + if (errorInfo.isAbort) { + if (runningEntry.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: false, + message: 'Pipeline stopped by user', + projectPath, + }); + } + } else { + logger.error(`Pipeline resume failed for ${featureId}:`, error); + await this.updateFeatureStatusFn(projectPath, featureId, 'backlog'); + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } finally { + this.concurrencyManager.release(featureId); + } + } + + /** Execute test step with agent fix loop (REQ-F07) */ + async executeTestStep(context: PipelineContext, testCommand: string): Promise { + const { featureId, projectPath, workDir, abortController, maxTestAttempts } = context; + + for (let attempt = 1; attempt <= maxTestAttempts; attempt++) { + if (abortController.signal.aborted) + return { success: false, message: 'Test execution aborted' }; + logger.info(`Running tests for ${featureId} (attempt ${attempt}/${maxTestAttempts})`); + + const testResult = await this.testRunnerService.startTests(workDir, { command: testCommand }); + if (!testResult.success || !testResult.result?.sessionId) + return { + success: false, + testsPassed: false, + message: testResult.error || 'Failed to start tests', + }; + + const completionResult = await this.waitForTestCompletion( + testResult.result.sessionId, + abortController.signal + ); + if (completionResult.status === 'passed') return { success: true, testsPassed: true }; + + const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId); + const scrollback = sessionOutput.result?.output || ''; + this.eventBus.emitAutoModeEvent('pipeline_test_failed', { + featureId, + attempt, + maxAttempts: maxTestAttempts, + failedTests: this.extractFailedTestNames(scrollback), + projectPath, + }); + + if (attempt < maxTestAttempts) { + const fixPrompt = `## Test Failures - Please Fix\n\n${this.buildTestFailureSummary(scrollback)}\n\nFix the failing tests without modifying test code unless clearly wrong.`; + await this.runAgentFn( + workDir, + featureId, + fixPrompt, + abortController, + projectPath, + undefined, + undefined, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt, + autoLoadClaudeMd: context.autoLoadClaudeMd, + reasoningEffort: context.feature.reasoningEffort, + } + ); + } + } + return { + success: false, + testsPassed: false, + message: `Tests failed after ${maxTestAttempts} attempts`, + }; + } + + /** Wait for test completion */ + private async waitForTestCompletion( + sessionId: string, + signal: AbortSignal + ): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + // Check for abort + if (signal.aborted) { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } + + const session = this.testRunnerService.getSession(sessionId); + if (session && session.status !== 'running' && session.status !== 'pending') { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve({ + status: session.status, + exitCode: session.exitCode, + duration: session.finishedAt + ? session.finishedAt.getTime() - session.startedAt.getTime() + : 0, + }); + } + }, 1000); + const timeoutId = setTimeout(() => { + // Check for abort before timeout resolution + if (signal.aborted) { + clearInterval(checkInterval); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } + clearInterval(checkInterval); + resolve({ status: 'failed', exitCode: null, duration: 600000 }); + }, 600000); + }); + } + + /** Attempt to merge feature branch (REQ-F05) */ + async attemptMerge(context: PipelineContext): Promise { + const { projectPath, featureId, branchName, worktreePath, feature } = context; + if (!branchName) return { success: false, error: 'No branch name for merge' }; + + logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`); + try { + // Get the primary branch dynamically instead of hardcoding 'main' + const targetBranch = await this.worktreeResolver.getCurrentBranch(projectPath); + + // Call merge service directly instead of HTTP fetch + const result = await performMerge( + projectPath, + branchName, + worktreePath || projectPath, + targetBranch || 'main', + { + deleteWorktreeAndBranch: false, + }, + this.eventBus.getUnderlyingEmitter() + ); + + if (!result.success) { + if (result.hasConflicts) { + await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict'); + this.eventBus.emitAutoModeEvent('pipeline_merge_conflict', { + featureId, + branchName, + projectPath, + }); + return { success: false, hasConflicts: true, needsAgentResolution: true }; + } + return { success: false, error: result.error }; + } + + logger.info(`Auto-merge successful for feature ${featureId}`); + const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForMerge?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed and merged', + projectPath, + }); + } + return { success: true }; + } catch (error) { + logger.error(`Merge failed for ${featureId}:`, error); + return { success: false, error: (error as Error).message }; + } + } + + /** Shared helper to parse test output lines and extract failure information */ + private parseTestLines(scrollback: string): { + failedTests: string[]; + passCount: number; + failCount: number; + } { + const lines = scrollback.split('\n'); + const failedTests: string[] = []; + let passCount = 0; + let failCount = 0; + + let inFailureContext = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) { + const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/); + if (match) failedTests.push(match[1].trim()); + failCount++; + inFailureContext = true; + } else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) { + passCount++; + inFailureContext = false; + } + if (trimmed.match(/^>\s+.*\.(test|spec)\./)) { + failedTests.push(trimmed.replace(/^>\s+/, '')); + } + // Only capture assertion details when they appear in failure context + // or match explicit assertion error / expect patterns + if (trimmed.includes('AssertionError')) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + /expect\(.+\)\.(toBe|toEqual|toMatch|toThrow|toContain)\s*\(/.test(trimmed) + ) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + (trimmed.startsWith('Expected') || trimmed.startsWith('Received')) + ) { + failedTests.push(trimmed); + } + } + + return { failedTests, passCount, failCount }; + } + + /** Build a concise test failure summary for the agent */ + buildTestFailureSummary(scrollback: string): string { + const { failedTests, passCount, failCount } = this.parseTestLines(scrollback); + const unique = [...new Set(failedTests)].slice(0, 10); + return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`; + } + + /** Extract failed test names from scrollback */ + private extractFailedTestNames(scrollback: string): string[] { + const { failedTests } = this.parseTestLines(scrollback); + return [...new Set(failedTests)].slice(0, 20); + } +} diff --git a/apps/server/src/services/pipeline-types.ts b/apps/server/src/services/pipeline-types.ts new file mode 100644 index 00000000..67957b6a --- /dev/null +++ b/apps/server/src/services/pipeline-types.ts @@ -0,0 +1,73 @@ +/** + * Pipeline Types - Type definitions for PipelineOrchestrator + */ + +import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types'; + +export interface PipelineContext { + projectPath: string; + featureId: string; + feature: Feature; + steps: PipelineStep[]; + workDir: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + autoLoadClaudeMd: boolean; + useClaudeCodeSystemPrompt?: boolean; + testAttempts: number; + maxTestAttempts: number; +} + +export interface PipelineStatusInfo { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; +} + +export interface StepResult { + success: boolean; + testsPassed?: boolean; + message?: string; +} + +export interface MergeResult { + success: boolean; + hasConflicts?: boolean; + needsAgentResolution?: boolean; + error?: string; +} + +export type UpdateFeatureStatusFn = ( + projectPath: string, + featureId: string, + status: string +) => Promise; + +export type BuildFeaturePromptFn = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } +) => string; + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + useScreenshots: boolean, + model?: string, + options?: { _calledInternally?: boolean } +) => Promise; + +export type RunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: Record +) => Promise; diff --git a/apps/server/src/services/plan-approval-service.ts b/apps/server/src/services/plan-approval-service.ts new file mode 100644 index 00000000..ebd37767 --- /dev/null +++ b/apps/server/src/services/plan-approval-service.ts @@ -0,0 +1,332 @@ +/** + * PlanApprovalService - Manages plan approval workflow with timeout and recovery + * + * Key behaviors: + * - Timeout stored in closure, wrapped resolve/reject ensures cleanup + * - Recovery returns needsRecovery flag (caller handles execution) + * - Auto-reject on timeout (safety feature, not auto-approve) + */ + +import { createLogger } from '@automaker/utils'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { FeatureStateManager } from './feature-state-manager.js'; +import type { SettingsService } from './settings-service.js'; + +const logger = createLogger('PlanApprovalService'); + +/** Result returned when approval is resolved */ +export interface PlanApprovalResult { + approved: boolean; + editedPlan?: string; + feedback?: string; +} + +/** Result returned from resolveApproval method */ +export interface ResolveApprovalResult { + success: boolean; + error?: string; + needsRecovery?: boolean; +} + +/** Represents an orphaned approval that needs recovery after server restart */ +export interface OrphanedApproval { + featureId: string; + projectPath: string; + generatedAt?: string; + planContent?: string; +} + +/** Internal: timeoutId stored in closure, NOT in this object */ +interface PendingApproval { + resolve: (result: PlanApprovalResult) => void; + reject: (error: Error) => void; + featureId: string; + projectPath: string; +} + +/** Default timeout: 30 minutes */ +const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60 * 1000; + +/** + * PlanApprovalService handles the plan approval workflow with lifecycle management. + */ +export class PlanApprovalService { + private pendingApprovals = new Map(); + private eventBus: TypedEventBus; + private featureStateManager: FeatureStateManager; + private settingsService: SettingsService | null; + + constructor( + eventBus: TypedEventBus, + featureStateManager: FeatureStateManager, + settingsService: SettingsService | null + ) { + this.eventBus = eventBus; + this.featureStateManager = featureStateManager; + this.settingsService = settingsService; + } + + /** Generate project-scoped key to prevent collisions across projects */ + private approvalKey(projectPath: string, featureId: string): string { + return `${projectPath}::${featureId}`; + } + + /** Wait for plan approval with timeout (default 30 min). Rejects on timeout/cancellation. */ + async waitForApproval(featureId: string, projectPath: string): Promise { + const timeoutMs = await this.getTimeoutMs(projectPath); + const timeoutMinutes = Math.round(timeoutMs / 60000); + const key = this.approvalKey(projectPath, featureId); + + logger.info(`Registering pending approval for feature ${featureId} in project ${projectPath}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + return new Promise((resolve, reject) => { + // Prevent duplicate registrations for the same key — reject and clean up existing entry + const existing = this.pendingApprovals.get(key); + if (existing) { + existing.reject(new Error('Superseded by a new waitForApproval call')); + this.pendingApprovals.delete(key); + } + + // Wrap resolve/reject to clear timeout when approval is resolved + // This ensures timeout is ALWAYS cleared on any resolution path + // Define wrappers BEFORE setTimeout so they can be used in timeout callback + let timeoutId: NodeJS.Timeout; + const wrappedResolve = (result: PlanApprovalResult) => { + clearTimeout(timeoutId); + resolve(result); + }; + + const wrappedReject = (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }; + + // Set up timeout to prevent indefinite waiting and memory leaks + // Now timeoutId assignment happens after wrappers are defined + timeoutId = setTimeout(() => { + const pending = this.pendingApprovals.get(key); + if (pending) { + logger.warn( + `Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes` + ); + this.pendingApprovals.delete(key); + wrappedReject( + new Error( + `Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled` + ) + ); + } + }, timeoutMs); + + this.pendingApprovals.set(key, { + resolve: wrappedResolve, + reject: wrappedReject, + featureId, + projectPath, + }); + + logger.info( + `Pending approval registered for feature ${featureId} (timeout: ${timeoutMinutes} minutes)` + ); + }); + } + + /** Resolve approval. Recovery path: returns needsRecovery=true if planSpec.status='generated'. */ + async resolveApproval( + featureId: string, + approved: boolean, + options?: { editedPlan?: string; feedback?: string; projectPath?: string } + ): Promise { + const { editedPlan, feedback, projectPath: projectPathFromClient } = options ?? {}; + + logger.info(`resolveApproval called for feature ${featureId}, approved=${approved}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + // Try to find pending approval using project-scoped key if projectPath is available + let foundKey: string | undefined; + let pending: PendingApproval | undefined; + + if (projectPathFromClient) { + foundKey = this.approvalKey(projectPathFromClient, featureId); + pending = this.pendingApprovals.get(foundKey); + } else { + // Fallback: search by featureId (backward compatibility) + for (const [key, approval] of this.pendingApprovals) { + if (approval.featureId === featureId) { + foundKey = key; + pending = approval; + break; + } + } + } + + if (!pending) { + logger.info(`No pending approval in Map for feature ${featureId}`); + + // RECOVERY: If no pending approval but we have projectPath from client, + // check if feature's planSpec.status is 'generated' and handle recovery + if (projectPathFromClient) { + logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`); + const feature = await this.featureStateManager.loadFeature( + projectPathFromClient, + featureId + ); + + if (feature?.planSpec?.status === 'generated') { + logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`); + + if (approved) { + // Update planSpec to approved + await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: true, + content: editedPlan || feature.planSpec.content, + }); + + logger.info(`Recovery approval complete for feature ${featureId}`); + + // Return needsRecovery flag - caller (AutoModeService) handles execution + return { success: true, needsRecovery: true }; + } else { + // Rejection recovery + await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'rejected', + reviewedByUser: true, + }); + + await this.featureStateManager.updateFeatureStatus( + projectPathFromClient, + featureId, + 'backlog' + ); + + this.eventBus.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath: projectPathFromClient, + feedback, + }); + + return { success: true }; + } + } + } + + logger.info( + `ERROR: No pending approval found for feature ${featureId} and recovery not possible` + ); + return { + success: false, + error: `No pending approval for feature ${featureId}`, + }; + } + + logger.info(`Found pending approval for feature ${featureId}, proceeding...`); + + const { projectPath } = pending; + + // Update feature's planSpec status + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: approved ? 'approved' : 'rejected', + approvedAt: approved ? new Date().toISOString() : undefined, + reviewedByUser: true, + ...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version + }); + + // If rejected, emit event so client knows the rejection reason (even without feedback) + if (!approved) { + this.eventBus.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath, + feedback, + }); + } + + // Resolve the promise with all data including feedback + // This triggers the wrapped resolve which clears the timeout + pending.resolve({ approved, editedPlan, feedback }); + if (foundKey) { + this.pendingApprovals.delete(foundKey); + } + + return { success: true }; + } + + /** Cancel approval (e.g., when feature stopped). Timeout cleared via wrapped reject. */ + cancelApproval(featureId: string, projectPath?: string): void { + logger.info(`cancelApproval called for feature ${featureId}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + // If projectPath provided, use project-scoped key; otherwise search by featureId + let foundKey: string | undefined; + let pending: PendingApproval | undefined; + + if (projectPath) { + foundKey = this.approvalKey(projectPath, featureId); + pending = this.pendingApprovals.get(foundKey); + } else { + // Fallback: search for any approval with this featureId (backward compatibility) + for (const [key, approval] of this.pendingApprovals) { + if (approval.featureId === featureId) { + foundKey = key; + pending = approval; + break; + } + } + } + + if (pending && foundKey) { + logger.info(`Found and cancelling pending approval for feature ${featureId}`); + // Wrapped reject clears timeout automatically + pending.reject(new Error('Plan approval cancelled - feature was stopped')); + this.pendingApprovals.delete(foundKey); + } else { + logger.info(`No pending approval to cancel for feature ${featureId}`); + } + } + + /** Check if a feature has a pending plan approval. */ + hasPendingApproval(featureId: string, projectPath?: string): boolean { + if (projectPath) { + return this.pendingApprovals.has(this.approvalKey(projectPath, featureId)); + } + // Fallback: search by featureId (backward compatibility) + for (const approval of this.pendingApprovals.values()) { + if (approval.featureId === featureId) { + return true; + } + } + return false; + } + + /** Get timeout from project settings or default (30 min). */ + private async getTimeoutMs(projectPath: string): Promise { + if (!this.settingsService) { + return DEFAULT_APPROVAL_TIMEOUT_MS; + } + + try { + const projectSettings = await this.settingsService.getProjectSettings(projectPath); + // Check for planApprovalTimeoutMs in project settings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const timeoutMs = (projectSettings as any).planApprovalTimeoutMs; + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + return timeoutMs; + } + } catch (error) { + logger.warn( + `Failed to get project settings for ${projectPath}, using default timeout`, + error + ); + } + + return DEFAULT_APPROVAL_TIMEOUT_MS; + } +} diff --git a/apps/server/src/services/pr-review-comments.service.ts b/apps/server/src/services/pr-review-comments.service.ts new file mode 100644 index 00000000..d4bc1388 --- /dev/null +++ b/apps/server/src/services/pr-review-comments.service.ts @@ -0,0 +1,431 @@ +/** + * PR Review Comments Service + * + * Domain logic for fetching PR review comments, enriching them with + * resolved-thread status, and sorting. Extracted from the route handler + * so the route only deals with request/response plumbing. + */ + +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import { execEnv, logError } from '../lib/exec-utils.js'; + +const execFileAsync = promisify(execFile); + +// ── Public types (re-exported for callers) ── + +export interface PRReviewComment { + id: string; + author: string; + avatarUrl?: string; + body: string; + path?: string; + line?: number; + createdAt: string; + updatedAt?: string; + isReviewComment: boolean; + /** Whether this is an outdated review comment (code has changed since) */ + isOutdated?: boolean; + /** Whether the review thread containing this comment has been resolved */ + isResolved?: boolean; + /** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */ + threadId?: string; + /** The diff hunk context for the comment */ + diffHunk?: string; + /** The side of the diff (LEFT or RIGHT) */ + side?: string; + /** The commit ID the comment was made on */ + commitId?: string; + /** Whether the comment author is a bot/app account */ + isBot?: boolean; +} + +export interface ListPRReviewCommentsResult { + success: boolean; + comments?: PRReviewComment[]; + totalCount?: number; + error?: string; +} + +// ── Internal types ── + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +/** Maximum number of pagination pages to prevent infinite loops */ +const MAX_PAGINATION_PAGES = 20; + +interface GraphQLReviewThreadComment { + databaseId: number; +} + +interface GraphQLReviewThread { + id: string; + isResolved: boolean; + comments: { + pageInfo?: { + hasNextPage: boolean; + endCursor?: string | null; + }; + nodes: GraphQLReviewThreadComment[]; + }; +} + +interface GraphQLResponse { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes: GraphQLReviewThread[]; + pageInfo?: { + hasNextPage: boolean; + endCursor?: string | null; + }; + }; + } | null; + }; + }; + errors?: Array<{ message: string }>; +} + +interface ReviewThreadInfo { + isResolved: boolean; + threadId: string; +} + +// ── Logger ── + +const logger = createLogger('PRReviewCommentsService'); + +// ── Service functions ── + +/** + * Execute a GraphQL query via the `gh` CLI and return the parsed response. + */ +async function executeGraphQL(projectPath: string, requestBody: string): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + reject(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.on('error', () => { + // Ignore stdin errors (e.g. when the child process is killed) + }); + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + return response; +} + +/** + * Fetch review thread resolved status and thread IDs using GitHub GraphQL API. + * Uses cursor-based pagination to handle PRs with more than 100 review threads. + * Returns a map of comment ID (string) -> { isResolved, threadId }. + */ +export async function fetchReviewThreadResolvedStatus( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise> { + const resolvedMap = new Map(); + + const query = ` + query GetPRReviewThreads( + $owner: String! + $repo: String! + $prNumber: Int! + $cursor: String + ) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 100) { + pageInfo { + hasNextPage + endCursor + } + nodes { + databaseId + } + } + } + } + } + } + }`; + + try { + let cursor: string | null = null; + let pageCount = 0; + + do { + const variables = { owner, repo, prNumber, cursor }; + const requestBody = JSON.stringify({ query, variables }); + const response = await executeGraphQL(projectPath, requestBody); + + const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads; + const threads = reviewThreads?.nodes ?? []; + + for (const thread of threads) { + if (thread.comments.pageInfo?.hasNextPage) { + logger.debug( + `Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` + + 'some comments may be missing resolved status' + ); + } + const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; + for (const comment of thread.comments.nodes) { + resolvedMap.set(String(comment.databaseId), info); + } + } + + const pageInfo = reviewThreads?.pageInfo; + if (pageInfo?.hasNextPage && pageInfo.endCursor) { + cursor = pageInfo.endCursor; + pageCount++; + logger.debug( + `Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})` + ); + } else { + cursor = null; + } + } while (cursor && pageCount < MAX_PAGINATION_PAGES); + + if (pageCount >= MAX_PAGINATION_PAGES) { + logger.warn( + `PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` + + 'pagination limit reached. Some comments may be missing resolved status.' + ); + } + } catch (error) { + // Log but don't fail — resolved status is best-effort + logError(error, 'Failed to fetch PR review thread resolved status'); + } + + return resolvedMap; +} + +/** + * Fetch all comments for a PR (regular, inline review, and review body comments) + */ +export async function fetchPRReviewComments( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise { + const allComments: PRReviewComment[] = []; + + // Fetch review thread resolved status in parallel with comment fetching + const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber); + + // 1. Fetch regular PR comments (issue-level comments) + // Uses the REST API issues endpoint instead of `gh pr view --json comments` + // because the latter uses GraphQL internally where bot/app authors can return + // null, causing bot comments to be silently dropped or display as "unknown". + try { + const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`; + const { stdout: commentsOutput } = await execFileAsync( + 'gh', + ['api', issueCommentsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const commentsData = JSON.parse(commentsOutput); + const regularComments = (Array.isArray(commentsData) ? commentsData : []).map( + (c: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + created_at: string; + updated_at?: string; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: String(c.id), + author: c.user?.login || c.performed_via_github_app?.slug || 'unknown', + avatarUrl: c.user?.avatar_url, + body: c.body, + createdAt: c.created_at, + updatedAt: c.updated_at, + isReviewComment: false, + isOutdated: false, + isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app, + // Regular PR comments are not part of review threads, so not resolvable + isResolved: false, + }) + ); + + allComments.push(...regularComments); + } catch (error) { + logError(error, 'Failed to fetch regular PR comments'); + } + + // 2. Fetch inline review comments (code-level comments with file/line info) + try { + const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`; + const { stdout: reviewsOutput } = await execFileAsync( + 'gh', + ['api', reviewsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const reviewsData = JSON.parse(reviewsOutput); + const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map( + (c: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + updated_at?: string; + diff_hunk?: string; + side?: string; + commit_id?: string; + position?: number | null; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: String(c.id), + author: c.user?.login || c.performed_via_github_app?.slug || 'unknown', + avatarUrl: c.user?.avatar_url, + body: c.body, + path: c.path, + line: c.line ?? c.original_line, + createdAt: c.created_at, + updatedAt: c.updated_at, + isReviewComment: true, + // A review comment is "outdated" if position is null (code has changed) + isOutdated: c.position === null, + // isResolved will be filled in below from GraphQL data + isResolved: false, + isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app, + diffHunk: c.diff_hunk, + side: c.side, + commitId: c.commit_id, + }) + ); + + allComments.push(...reviewComments); + } catch (error) { + logError(error, 'Failed to fetch inline review comments'); + } + + // 3. Fetch review body comments (summary text submitted with each review) + // These are the top-level comments written when submitting a review + // (Approve, Request Changes, Comment). They are separate from inline code comments + // and issue-level comments. Only include reviews that have a non-empty body. + try { + const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/reviews`; + const { stdout: reviewBodiesOutput } = await execFileAsync( + 'gh', + ['api', reviewsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const reviewBodiesData = JSON.parse(reviewBodiesOutput); + const reviewBodyComments = (Array.isArray(reviewBodiesData) ? reviewBodiesData : []) + .filter( + (r: { body?: string; state?: string }) => + r.body && r.body.trim().length > 0 && r.state !== 'PENDING' + ) + .map( + (r: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + state: string; + submitted_at: string; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: `review-${r.id}`, + author: r.user?.login || r.performed_via_github_app?.slug || 'unknown', + avatarUrl: r.user?.avatar_url, + body: r.body, + createdAt: r.submitted_at, + isReviewComment: false, + isOutdated: false, + isResolved: false, + isBot: r.user?.type === 'Bot' || !!r.performed_via_github_app, + }) + ); + + allComments.push(...reviewBodyComments); + } catch (error) { + logError(error, 'Failed to fetch review body comments'); + } + + // Wait for resolved status and apply to inline review comments + const resolvedMap = await resolvedStatusPromise; + for (const comment of allComments) { + if (comment.isReviewComment && resolvedMap.has(comment.id)) { + const info = resolvedMap.get(comment.id)!; + comment.isResolved = info.isResolved; + comment.threadId = info.threadId; + } + } + + // Sort by createdAt descending (newest first) + allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return allComments; +} diff --git a/apps/server/src/services/pr-service.ts b/apps/server/src/services/pr-service.ts new file mode 100644 index 00000000..4b26f35b --- /dev/null +++ b/apps/server/src/services/pr-service.ts @@ -0,0 +1,225 @@ +/** + * Service for resolving PR target information from git remotes. + * + * Extracts remote-parsing and target-resolution logic that was previously + * inline in the create-pr route handler. + */ + +// TODO: Move execAsync/execEnv to a shared lib (lib/exec.ts or @automaker/utils) so that +// services no longer depend on route internals. Tracking issue: route-to-service dependency +// inversion. For now, a local thin wrapper is used within the service boundary. +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { createLogger, isValidRemoteName } from '@automaker/utils'; + +// Thin local wrapper — duplicates the route-level execAsync/execEnv until a +// shared lib/exec.ts (or @automaker/utils export) is created. +const execAsync = promisify(exec); + +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const _additionalPaths: string[] = []; +if (process.platform === 'win32') { + if (process.env.LOCALAPPDATA) + _additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + if (process.env.PROGRAMFILES) _additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + if (process.env['ProgramFiles(x86)']) + _additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); +} else { + _additionalPaths.push( + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin` + ); +} +const execEnv = { + ...process.env, + PATH: [process.env.PATH, ..._additionalPaths.filter(Boolean)].filter(Boolean).join(pathSeparator), +}; + +const logger = createLogger('PRService'); + +export interface ParsedRemote { + owner: string; + repo: string; +} + +export interface PrTargetResult { + repoUrl: string | null; + targetRepo: string | null; + pushOwner: string | null; + upstreamRepo: string | null; + originOwner: string | null; + parsedRemotes: Map; +} + +/** + * Parse all git remotes for the given repo path and resolve the PR target. + * + * @param worktreePath - Working directory of the repository / worktree + * @param pushRemote - Remote used for pushing (e.g. "origin") + * @param targetRemote - Explicit remote to target the PR against (optional) + * + * @throws {Error} When targetRemote is specified but not found among repository remotes + * @throws {Error} When pushRemote is not found among parsed remotes (when targetRemote is specified) + */ +export async function resolvePrTarget({ + worktreePath, + pushRemote, + targetRemote, +}: { + worktreePath: string; + pushRemote: string; + targetRemote?: string; +}): Promise { + // Validate remote names — pushRemote is a required string so the undefined + // guard is unnecessary, but targetRemote is optional. + if (!isValidRemoteName(pushRemote)) { + throw new Error(`Invalid push remote name: "${pushRemote}"`); + } + if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) { + throw new Error(`Invalid target remote name: "${targetRemote}"`); + } + + let repoUrl: string | null = null; + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + const parsedRemotes: Map = new Map(); + + try { + const { stdout: remotes } = await execAsync('git remote -v', { + cwd: worktreePath, + env: execEnv, + }); + + // Parse remotes to detect fork workflow and get repo URL + const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings + for (const line of lines) { + // Try multiple patterns to match different remote URL formats + // Pattern 1: git@github.com:owner/repo.git (fetch) + // Pattern 2: https://github.com/owner/repo.git (fetch) + // Pattern 3: https://github.com/owner/repo (fetch) + let match = line.match( + /^([a-zA-Z0-9._-]+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + if (!match) { + // Try SSH format: git@github.com:owner/repo.git + match = line.match( + /^([a-zA-Z0-9._-]+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + } + if (!match) { + // Try HTTPS format: https://github.com/owner/repo.git + match = line.match( + /^([a-zA-Z0-9._-]+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + } + + if (match) { + const [, remoteName, owner, repo] = match; + parsedRemotes.set(remoteName, { owner, repo }); + if (remoteName === 'upstream') { + upstreamRepo = `${owner}/${repo}`; + repoUrl = `https://github.com/${owner}/${repo}`; + } else if (remoteName === 'origin') { + originOwner = owner; + if (!repoUrl) { + repoUrl = `https://github.com/${owner}/${repo}`; + } + } + } + } + } catch (err) { + // Log the failure for debugging — control flow falls through to auto-detection + logger.debug('Failed to parse git remotes', { worktreePath, error: err }); + } + + // When targetRemote is explicitly provided but remote parsing failed entirely + // (parsedRemotes is empty), we cannot validate or resolve the requested remote. + // Silently proceeding to auto-detection would ignore the caller's explicit intent, + // so we fail fast with a clear error instead. + if (targetRemote && parsedRemotes.size === 0) { + throw new Error( + `targetRemote "${targetRemote}" was specified but no remotes could be parsed from the repository. ` + + `Ensure the repository has at least one configured remote (parsedRemotes is empty).` + ); + } + + // When a targetRemote is explicitly specified, validate that it is known + // before using it. Silently falling back to auto-detection when the caller + // explicitly requested a remote that doesn't exist is misleading, so we + // fail fast here instead. + if (targetRemote && parsedRemotes.size > 0 && !parsedRemotes.has(targetRemote)) { + throw new Error(`targetRemote "${targetRemote}" not found in repository remotes`); + } + + // When a targetRemote is explicitly specified, override fork detection + // to use the specified remote as the PR target + let targetRepo: string | null = null; + let pushOwner: string | null = null; + if (targetRemote && parsedRemotes.size > 0) { + const targetInfo = parsedRemotes.get(targetRemote); + const pushInfo = parsedRemotes.get(pushRemote); + + // If the push remote is not found in the parsed remotes, we cannot + // determine the push owner and would build incorrect URLs. Fail fast + // instead of silently proceeding with null values. + if (!pushInfo) { + logger.warn('Push remote not found in parsed remotes', { + pushRemote, + targetRemote, + availableRemotes: [...parsedRemotes.keys()], + }); + throw new Error(`Push remote "${pushRemote}" not found in repository remotes`); + } + + if (targetInfo) { + targetRepo = `${targetInfo.owner}/${targetInfo.repo}`; + repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`; + } + pushOwner = pushInfo.owner; + + // Override the auto-detected upstream/origin with explicit targetRemote + // Only treat as cross-remote if target differs from push remote + if (targetRemote !== pushRemote && targetInfo) { + upstreamRepo = targetRepo; + originOwner = pushOwner; + } else if (targetInfo) { + // Same remote for push and target - regular (non-fork) workflow + upstreamRepo = null; + originOwner = targetInfo.owner; + repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`; + } + } + + // Fallback: Try to get repo URL from git config if remote parsing failed + if (!repoUrl) { + try { + const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { + cwd: worktreePath, + env: execEnv, + }); + const url = originUrl.trim(); + + // Parse URL to extract owner/repo + // Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) + const match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); + if (match) { + const [, owner, repo] = match; + originOwner = owner; + repoUrl = `https://github.com/${owner}/${repo}`; + } + } catch { + // Failed to get repo URL from config + } + } + + return { + repoUrl, + targetRepo, + pushOwner, + upstreamRepo, + originOwner, + parsedRemotes, + }; +} diff --git a/apps/server/src/services/pull-service.ts b/apps/server/src/services/pull-service.ts new file mode 100644 index 00000000..82531423 --- /dev/null +++ b/apps/server/src/services/pull-service.ts @@ -0,0 +1,533 @@ +/** + * PullService - Pull git operations without HTTP + * + * Encapsulates the full git pull workflow including: + * - Branch name and detached HEAD detection + * - Fetching from remote + * - Status parsing and local change detection + * - Stash push/pop logic + * - Upstream verification (rev-parse / --verify) + * - Pull execution and conflict detection + * - Conflict file list collection + * + * Extracted from the worktree pull route to improve organization + * and testability. Follows the same pattern as rebase-service.ts + * and cherry-pick-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand, getConflictFiles } from '@automaker/git-utils'; +import { execGitCommandWithLockRetry, getCurrentBranch } from '../lib/git.js'; + +const logger = createLogger('PullService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface PullOptions { + /** Remote name to pull from (defaults to 'origin') */ + remote?: string; + /** When true, automatically stash local changes before pulling and reapply after */ + stashIfNeeded?: boolean; +} + +export interface PullResult { + success: boolean; + error?: string; + branch?: string; + pulled?: boolean; + hasLocalChanges?: boolean; + localChangedFiles?: string[]; + stashed?: boolean; + stashRestored?: boolean; + stashRecoveryFailed?: boolean; + hasConflicts?: boolean; + conflictSource?: 'pull' | 'stash'; + conflictFiles?: string[]; + message?: string; + /** Whether the pull resulted in a merge commit (not fast-forward) */ + isMerge?: boolean; + /** Whether the pull was a fast-forward (no merge commit needed) */ + isFastForward?: boolean; + /** Files affected by the merge (only present when isMerge is true) */ + mergeAffectedFiles?: string[]; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Fetch the latest refs from a remote. + * + * @param worktreePath - Path to the git worktree + * @param remote - Remote name (e.g. 'origin') + */ +export async function fetchRemote(worktreePath: string, remote: string): Promise { + await execGitCommand(['fetch', remote], worktreePath); +} + +/** + * Parse `git status --porcelain` output into a list of changed file paths. + * + * @param worktreePath - Path to the git worktree + * @returns Object with hasLocalChanges flag and list of changed file paths + */ +export async function getLocalChanges( + worktreePath: string +): Promise<{ hasLocalChanges: boolean; localChangedFiles: string[] }> { + const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath); + const hasLocalChanges = statusOutput.trim().length > 0; + + let localChangedFiles: string[] = []; + if (hasLocalChanges) { + localChangedFiles = statusOutput + .trim() + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => { + const entry = line.substring(3).trim(); + const arrowIndex = entry.indexOf(' -> '); + return arrowIndex !== -1 ? entry.substring(arrowIndex + 4).trim() : entry; + }); + } + + return { hasLocalChanges, localChangedFiles }; +} + +/** + * Stash local changes with a descriptive message. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name (used in stash message) + * @returns Promise — resolves on success, throws on failure + */ +export async function stashChanges(worktreePath: string, branchName: string): Promise { + const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`; + await execGitCommandWithLockRetry( + ['stash', 'push', '--include-untracked', '-m', stashMessage], + worktreePath + ); +} + +/** + * Pop the top stash entry. + * + * @param worktreePath - Path to the git worktree + * @returns The stdout from stash pop + */ +export async function popStash(worktreePath: string): Promise { + return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); +} + +/** + * Try to pop the stash, returning whether the pop succeeded. + * + * @param worktreePath - Path to the git worktree + * @returns true if stash pop succeeded, false if it failed + */ +async function tryPopStash(worktreePath: string): Promise { + try { + await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); + return true; + } catch (stashPopError) { + // Stash pop failed - leave it in stash list for manual recovery + logger.error('Failed to reapply stash during error recovery', { + worktreePath, + error: getErrorMessage(stashPopError), + }); + return false; + } +} + +/** + * Result of the upstream/remote branch check. + * - 'tracking': the branch has a configured upstream tracking ref + * - 'remote': no tracking ref, but the remote branch exists + * - 'none': neither a tracking ref nor a remote branch was found + */ +export type UpstreamStatus = 'tracking' | 'remote' | 'none'; + +/** + * Check whether the branch has an upstream tracking ref, or whether + * the remote branch exists. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name + * @param remote - Remote name + * @returns UpstreamStatus indicating tracking ref, remote branch, or neither + */ +export async function hasUpstreamOrRemoteBranch( + worktreePath: string, + branchName: string, + remote: string +): Promise { + try { + await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath); + return 'tracking'; + } catch { + // No upstream tracking - check if the remote branch exists + try { + await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath); + return 'remote'; + } catch { + return 'none'; + } + } +} + +/** + * Check whether an error output string indicates a merge conflict. + */ +function isConflictError(errorOutput: string): boolean { + return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); +} + +/** + * Determine whether the current HEAD commit is a merge commit by checking + * whether it has two or more parent hashes. + * + * Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated + * by spaces. A merge commit has at least two parents; a regular commit has one. + * + * @param worktreePath - Path to the git worktree + * @returns true if HEAD is a merge commit, false otherwise + */ +async function isMergeCommit(worktreePath: string): Promise { + try { + const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath); + // Each parent SHA is separated by a space; two or more means it's a merge + const parents = output + .trim() + .split(/\s+/) + .filter((p) => p.length > 0); + return parents.length >= 2; + } catch { + // If the check fails for any reason, assume it is not a merge commit + return false; + } +} + +/** + * Check whether an output string indicates a stash conflict. + */ +function isStashConflict(output: string): boolean { + return output.includes('CONFLICT') || output.includes('Merge conflict'); +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a full git pull workflow on the given worktree. + * + * The workflow: + * 1. Get current branch name (detect detached HEAD) + * 2. Fetch from remote + * 3. Check for local changes + * 4. If local changes and stashIfNeeded, stash them + * 5. Verify upstream tracking or remote branch exists + * 6. Execute `git pull` + * 7. If stash was created and pull succeeded, reapply stash + * 8. Detect and report conflicts from pull or stash reapplication + * + * @param worktreePath - Path to the git worktree + * @param options - Pull options (remote, stashIfNeeded) + * @returns PullResult with detailed status information + */ +export async function performPull( + worktreePath: string, + options?: PullOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + const stashIfNeeded = options?.stashIfNeeded ?? false; + + // 1. Get current branch name + let branchName: string; + try { + branchName = await getCurrentBranch(worktreePath); + } catch (err) { + return { + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }; + } + + // 2. Check for detached HEAD state + if (branchName === 'HEAD') { + return { + success: false, + error: 'Cannot pull in detached HEAD state. Please checkout a branch first.', + }; + } + + // 3. Fetch latest from remote + try { + await fetchRemote(worktreePath, targetRemote); + } catch (fetchError) { + return { + success: false, + error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`, + }; + } + + // 4. Check for local changes + let hasLocalChanges: boolean; + let localChangedFiles: string[]; + try { + ({ hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath)); + } catch (err) { + return { + success: false, + error: `Failed to get local changes: ${getErrorMessage(err)}`, + }; + } + + // 5. If there are local changes and stashIfNeeded is not requested, return info + if (hasLocalChanges && !stashIfNeeded) { + return { + success: true, + branch: branchName, + pulled: false, + hasLocalChanges: true, + localChangedFiles, + message: + 'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.', + }; + } + + // 6. Stash local changes if needed + let didStash = false; + if (hasLocalChanges && stashIfNeeded) { + try { + await stashChanges(worktreePath, branchName); + didStash = true; + } catch (stashError) { + return { + success: false, + error: `Failed to stash local changes: ${getErrorMessage(stashError)}`, + }; + } + } + + // 7. Verify upstream tracking or remote branch exists + const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); + if (upstreamStatus === 'none') { + let stashRecoveryFailed = false; + if (didStash) { + const stashPopped = await tryPopStash(worktreePath); + stashRecoveryFailed = !stashPopped; + } + return { + success: false, + error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + + // 8. Pull latest changes + // When the branch has a configured upstream tracking ref, let Git use it automatically. + // When only the remote branch exists (no tracking ref), explicitly specify remote and branch. + const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; + let pullConflict = false; + let pullConflictFiles: string[] = []; + + // Declare merge detection variables before the try block so they are accessible + // in the stash reapplication path even when didStash is true. + let isMerge = false; + let isFastForward = false; + let mergeAffectedFiles: string[] = []; + + try { + const pullOutput = await execGitCommand(pullArgs, worktreePath); + + const alreadyUpToDate = pullOutput.includes('Already up to date'); + // Detect fast-forward from git pull output + isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward'); + // Detect merge by checking whether the new HEAD has two parents (more reliable + // than string-matching localised pull output which may not contain 'Merge'). + isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false; + + // If it was a real merge (not fast-forward), get the affected files + if (isMerge) { + try { + // Get files changed in the merge commit + const diffOutput = await execGitCommand( + ['diff', '--name-only', 'HEAD~1', 'HEAD'], + worktreePath + ); + mergeAffectedFiles = diffOutput + .trim() + .split('\n') + .filter((f: string) => f.trim().length > 0); + } catch { + // Ignore errors - this is best-effort + } + } + + // If no stash to reapply, return success + if (!didStash) { + return { + success: true, + branch: branchName, + pulled: !alreadyUpToDate, + hasLocalChanges: false, + stashed: false, + stashRestored: false, + message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', + ...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}), + ...(isFastForward ? { isFastForward: true } : {}), + }; + } + } catch (pullError: unknown) { + const err = pullError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + if (isConflictError(errorOutput)) { + pullConflict = true; + try { + pullConflictFiles = await getConflictFiles(worktreePath); + } catch { + pullConflictFiles = []; + } + } else { + // Non-conflict pull error + let stashRecoveryFailed = false; + if (didStash) { + const stashPopped = await tryPopStash(worktreePath); + stashRecoveryFailed = !stashPopped; + } + + // Check for common errors + const errorMsg = err.stderr || err.message || 'Pull failed'; + if (errorMsg.includes('no tracking information')) { + return { + success: false, + error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + + return { + success: false, + error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + } + + // 9. If pull had conflicts, return conflict info (don't try stash pop) + if (pullConflict) { + return { + success: false, + branch: branchName, + pulled: true, + hasConflicts: true, + conflictSource: 'pull', + conflictFiles: pullConflictFiles, + stashed: didStash, + stashRestored: false, + message: + `Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(), + }; + } + + // 10. Pull succeeded, now try to reapply stash + if (didStash) { + return await reapplyStash(worktreePath, branchName, { + isMerge, + isFastForward, + mergeAffectedFiles, + }); + } + + // Shouldn't reach here, but return a safe default + return { + success: true, + branch: branchName, + pulled: true, + message: 'Pulled latest changes', + }; +} + +/** + * Attempt to reapply stashed changes after a successful pull. + * Handles both clean reapplication and conflict scenarios. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name + * @param mergeInfo - Merge/fast-forward detection info from the pull step + * @returns PullResult reflecting stash reapplication status + */ +async function reapplyStash( + worktreePath: string, + branchName: string, + mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] } +): Promise { + const mergeFields: Partial = { + ...(mergeInfo.isMerge + ? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles } + : {}), + ...(mergeInfo.isFastForward ? { isFastForward: true } : {}), + }; + + try { + await popStash(worktreePath); + + // Stash pop succeeded cleanly (popStash throws on non-zero exit) + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: false, + stashed: true, + stashRestored: true, + ...mergeFields, + message: 'Pulled latest changes and restored your stashed changes.', + }; + } catch (stashPopError: unknown) { + const err = stashPopError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + // Check if stash pop failed due to conflicts + // The stash remains in the stash list when conflicts occur, so stashRestored is false + if (isStashConflict(errorOutput)) { + let stashConflictFiles: string[] = []; + try { + stashConflictFiles = await getConflictFiles(worktreePath); + } catch { + stashConflictFiles = []; + } + + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: true, + conflictSource: 'stash', + conflictFiles: stashConflictFiles, + stashed: true, + stashRestored: false, + ...mergeFields, + message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', + }; + } + + // Non-conflict stash pop error - stash is still in the stash list + logger.warn('Failed to reapply stash after pull', { worktreePath, error: errorOutput }); + + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: false, + stashed: true, + stashRestored: false, + ...mergeFields, + message: + 'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.', + }; + } +} diff --git a/apps/server/src/services/push-service.ts b/apps/server/src/services/push-service.ts new file mode 100644 index 00000000..f1619f5b --- /dev/null +++ b/apps/server/src/services/push-service.ts @@ -0,0 +1,258 @@ +/** + * PushService - Push git operations without HTTP + * + * Encapsulates the full git push workflow including: + * - Branch name and detached HEAD detection + * - Safe array-based command execution (no shell interpolation) + * - Divergent branch detection and auto-resolution via pull-then-retry + * - Structured result reporting + * + * Mirrors the pull-service.ts pattern for consistency. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '@automaker/git-utils'; +import { getCurrentBranch } from '../lib/git.js'; +import { performPull } from './pull-service.js'; + +const logger = createLogger('PushService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface PushOptions { + /** Remote name to push to (defaults to 'origin') */ + remote?: string; + /** Force push */ + force?: boolean; + /** When true and push is rejected due to divergence, pull then retry push */ + autoResolve?: boolean; +} + +export interface PushResult { + success: boolean; + error?: string; + branch?: string; + pushed?: boolean; + /** Whether the push was initially rejected because the branches diverged */ + diverged?: boolean; + /** Whether divergence was automatically resolved via pull-then-retry */ + autoResolved?: boolean; + /** Whether the auto-resolve pull resulted in merge conflicts */ + hasConflicts?: boolean; + /** Files with merge conflicts (only when hasConflicts is true) */ + conflictFiles?: string[]; + message?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Detect whether push error output indicates a diverged/non-fast-forward rejection. + */ +function isDivergenceError(errorOutput: string): boolean { + const lower = errorOutput.toLowerCase(); + // Require specific divergence indicators rather than just 'rejected' alone, + // which could match pre-receive hook rejections or protected branch errors. + const hasNonFastForward = lower.includes('non-fast-forward'); + const hasFetchFirst = lower.includes('fetch first'); + const hasFailedToPush = lower.includes('failed to push some refs'); + const hasRejected = lower.includes('rejected'); + return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush); +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a git push on the given worktree. + * + * The workflow: + * 1. Get current branch name (detect detached HEAD) + * 2. Attempt `git push ` with safe array args + * 3. If push fails with divergence and autoResolve is true: + * a. Pull from the same remote (with stash support) + * b. If pull succeeds without conflicts, retry push + * 4. If push fails with "no upstream" error, retry with --set-upstream + * 5. Return structured result + * + * @param worktreePath - Path to the git worktree + * @param options - Push options (remote, force, autoResolve) + * @returns PushResult with detailed status information + */ +export async function performPush( + worktreePath: string, + options?: PushOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + const force = options?.force ?? false; + const autoResolve = options?.autoResolve ?? false; + + // 1. Get current branch name + let branchName: string; + try { + branchName = await getCurrentBranch(worktreePath); + } catch (err) { + return { + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }; + } + + // 2. Check for detached HEAD state + if (branchName === 'HEAD') { + return { + success: false, + error: 'Cannot push in detached HEAD state. Please checkout a branch first.', + }; + } + + // 3. Build push args (no -u flag; upstream is set in the fallback path only when needed) + const pushArgs = ['push', targetRemote, branchName]; + if (force) { + pushArgs.push('--force'); + } + + // 4. Attempt push + try { + await execGitCommand(pushArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + message: `Successfully pushed ${branchName} to ${targetRemote}`, + }; + } catch (pushError: unknown) { + const err = pushError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + // 5. Check if the error is a divergence rejection + if (isDivergenceError(errorOutput)) { + if (!autoResolve) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`, + message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`, + }; + } + + // 6. Auto-resolve: pull then retry push + logger.info('Push rejected due to divergence, attempting auto-resolve via pull', { + worktreePath, + remote: targetRemote, + branch: branchName, + }); + + try { + const pullResult = await performPull(worktreePath, { + remote: targetRemote, + stashIfNeeded: true, + }); + + if (!pullResult.success) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Auto-resolve failed during pull: ${pullResult.error}`, + }; + } + + if (pullResult.hasConflicts) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + hasConflicts: true, + conflictFiles: pullResult.conflictFiles, + error: + 'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.', + }; + } + + // 7. Retry push after successful pull + try { + await execGitCommand(pushArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + diverged: true, + autoResolved: true, + message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`, + }; + } catch (retryError: unknown) { + const retryErr = retryError as { stderr?: string; message?: string }; + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`, + }; + } + } catch (pullError) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`, + }; + } + } + + // 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream + const isNoUpstreamError = + errorOutput.toLowerCase().includes('no upstream') || + errorOutput.toLowerCase().includes('has no upstream branch') || + errorOutput.toLowerCase().includes('set-upstream'); + if (isNoUpstreamError) { + try { + const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName]; + if (force) { + setUpstreamArgs.push('--force'); + } + await execGitCommand(setUpstreamArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`, + }; + } catch (upstreamError: unknown) { + const upstreamErr = upstreamError as { stderr?: string; message?: string }; + return { + success: false, + branch: branchName, + pushed: false, + error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError), + }; + } + } + + // 6c. Other push error - return as-is + return { + success: false, + branch: branchName, + pushed: false, + error: err.stderr || err.message || getErrorMessage(pushError), + }; + } +} diff --git a/apps/server/src/services/rebase-service.ts b/apps/server/src/services/rebase-service.ts new file mode 100644 index 00000000..758a667b --- /dev/null +++ b/apps/server/src/services/rebase-service.ts @@ -0,0 +1,260 @@ +/** + * RebaseService - Rebase git operations without HTTP + * + * Handles git rebase operations with conflict detection and reporting. + * Follows the same pattern as merge-service.ts and cherry-pick-service.ts. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { createLogger, getErrorMessage, isValidRemoteName } from '@automaker/utils'; +import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils'; + +const logger = createLogger('RebaseService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface RebaseOptions { + /** Remote name to fetch from before rebasing (defaults to 'origin') */ + remote?: string; +} + +export interface RebaseResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + conflictFiles?: string[]; + aborted?: boolean; + branch?: string; + ontoBranch?: string; + message?: string; +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * Run a git rebase operation on the given worktree. + * + * @param worktreePath - Path to the git worktree + * @param ontoBranch - The branch to rebase onto (e.g., 'origin/main') + * @param options - Optional rebase options (remote name for fetch) + * @returns RebaseResult with success/failure information + */ +export async function runRebase( + worktreePath: string, + ontoBranch: string, + options?: RebaseOptions +): Promise { + // Reject empty, whitespace-only, or dash-prefixed branch names. + const normalizedOntoBranch = ontoBranch?.trim() ?? ''; + if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) { + return { + success: false, + error: `Invalid branch name: "${ontoBranch}" must not be empty or start with a dash.`, + }; + } + + // Get current branch name before rebase + let currentBranch: string; + try { + currentBranch = await getCurrentBranch(worktreePath); + } catch (branchErr) { + return { + success: false, + error: `Failed to resolve current branch for worktree "${worktreePath}": ${getErrorMessage(branchErr)}`, + }; + } + + // Validate the remote name to prevent git option injection. + // Reject invalid remote names so the caller knows their input was wrong, + // consistent with how invalid branch names are handled above. + const remote = options?.remote || 'origin'; + if (!isValidRemoteName(remote)) { + logger.warn('Invalid remote name supplied to rebase-service', { + remote, + worktreePath, + }); + return { + success: false, + error: `Invalid remote name: "${remote}"`, + }; + } + + // Fetch latest from remote before rebasing to ensure we have up-to-date refs + try { + await execGitCommand(['fetch', remote], worktreePath); + } catch (fetchError) { + logger.warn('Failed to fetch from remote before rebase; proceeding with local refs', { + remote, + worktreePath, + error: getErrorMessage(fetchError), + }); + // Non-fatal: proceed with local refs if fetch fails (e.g. offline) + } + + try { + // Pass ontoBranch after '--' so git treats it as a ref, not an option. + // Set LC_ALL=C so git always emits English output regardless of the system + // locale, making text-based conflict detection reliable. + await execGitCommand(['rebase', '--', normalizedOntoBranch], worktreePath, { LC_ALL: 'C' }); + + return { + success: true, + branch: currentBranch, + ontoBranch: normalizedOntoBranch, + message: `Successfully rebased ${currentBranch} onto ${normalizedOntoBranch}`, + }; + } catch (rebaseError: unknown) { + // Check if this is a rebase conflict. We use a multi-layer strategy so + // that detection is reliable even when locale settings vary or git's text + // output changes across versions: + // + // 1. Primary (text-based): scan the error output for well-known English + // conflict markers. Because we pass LC_ALL=C above these strings are + // always in English, but we keep the check as one layer among several. + // + // 2. Repository-state check: run `git rev-parse --git-dir` to find the + // actual .git directory, then verify whether the in-progress rebase + // state directories (.git/rebase-merge or .git/rebase-apply) exist. + // These are created by git at the start of a rebase and are the most + // reliable indicator that a rebase is still in progress (i.e. stopped + // due to conflicts). + // + // 3. Unmerged-path check: run `git status --porcelain` (machine-readable, + // locale-independent) and look for lines whose first two characters + // indicate an unmerged state (UU, AA, DD, AU, UA, DU, UD). + // + // hasConflicts is true when ANY of the three layers returns positive. + const err = rebaseError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + + // Layer 1 – text matching (locale-safe because we set LC_ALL=C above). + const textIndicatesConflict = + output.includes('CONFLICT') || + output.includes('could not apply') || + output.includes('Resolve all conflicts') || + output.includes('fix conflicts'); + + // Layers 2 & 3 – repository state inspection (locale-independent). + let rebaseStateExists = false; + let hasUnmergedPaths = false; + try { + // Find the canonical .git directory for this worktree. + const gitDir = (await execGitCommand(['rev-parse', '--git-dir'], worktreePath)).trim(); + // git rev-parse --git-dir returns a path relative to cwd when the repo is + // a worktree, so we resolve it against worktreePath. + const resolvedGitDir = path.resolve(worktreePath, gitDir); + + // Layer 2: check for rebase state directories. + const rebaseMergeDir = path.join(resolvedGitDir, 'rebase-merge'); + const rebaseApplyDir = path.join(resolvedGitDir, 'rebase-apply'); + const [rebaseMergeExists, rebaseApplyExists] = await Promise.all([ + fs + .access(rebaseMergeDir) + .then(() => true) + .catch(() => false), + fs + .access(rebaseApplyDir) + .then(() => true) + .catch(() => false), + ]); + rebaseStateExists = rebaseMergeExists || rebaseApplyExists; + } catch { + // If rev-parse fails the repo may be in an unexpected state; fall back to + // text-based detection only. + } + + try { + // Layer 3: check for unmerged paths via machine-readable git status. + const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath, { + LC_ALL: 'C', + }); + // Unmerged status codes occupy the first two characters of each line. + // Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD. + hasUnmergedPaths = statusOutput + .split('\n') + .some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + } catch { + // git status failing is itself a sign something is wrong; leave + // hasUnmergedPaths as false and rely on the other layers. + } + + const hasConflicts = textIndicatesConflict || rebaseStateExists || hasUnmergedPaths; + + if (hasConflicts) { + // Attempt to fetch the list of conflicted files. We wrap this in its + // own try/catch so that a failure here does NOT prevent abortRebase from + // running – keeping the repository in a clean state is the priority. + let conflictFiles: string[] | undefined; + let conflictFilesError: unknown; + try { + conflictFiles = await getConflictFiles(worktreePath); + } catch (getConflictFilesError: unknown) { + conflictFilesError = getConflictFilesError; + logger.warn('Failed to retrieve conflict files after rebase conflict', { + worktreePath, + error: getErrorMessage(getConflictFilesError), + }); + } + + // Abort the rebase to leave the repo in a clean state. This must + // always run regardless of whether getConflictFiles succeeded. + const aborted = await abortRebase(worktreePath); + + if (!aborted) { + logger.error('Failed to abort rebase after conflict; repository may be in a dirty state', { + worktreePath, + }); + } + + // Re-throw a composed error so callers retain both the original rebase + // failure context and any conflict-file lookup failure. + if (conflictFilesError !== undefined) { + const composedMessage = [ + `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" failed due to conflicts.`, + `Original rebase error: ${getErrorMessage(rebaseError)}`, + `Additionally, fetching conflict files failed: ${getErrorMessage(conflictFilesError)}`, + aborted + ? 'The rebase was aborted; no changes were applied.' + : 'The rebase abort also failed; repository may be in a dirty state.', + ].join(' '); + throw new Error(composedMessage); + } + + return { + success: false, + error: aborted + ? `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" aborted due to conflicts; no changes were applied.` + : `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" failed due to conflicts and the abort also failed; repository may be in a dirty state.`, + hasConflicts: true, + conflictFiles, + aborted, + branch: currentBranch, + ontoBranch: normalizedOntoBranch, + }; + } + + // Non-conflict error - propagate + throw rebaseError; + } +} + +/** + * Abort an in-progress rebase operation. + * + * @param worktreePath - Path to the git worktree + * @returns true if abort succeeded, false if it failed (logged as warning) + */ +export async function abortRebase(worktreePath: string): Promise { + try { + await execGitCommand(['rebase', '--abort'], worktreePath); + return true; + } catch (err) { + logger.warn('Failed to abort rebase after conflict', err instanceof Error ? err.message : err); + return false; + } +} diff --git a/apps/server/src/services/recovery-service.ts b/apps/server/src/services/recovery-service.ts new file mode 100644 index 00000000..d08f5a8e --- /dev/null +++ b/apps/server/src/services/recovery-service.ts @@ -0,0 +1,333 @@ +/** + * RecoveryService - Crash recovery and feature resumption + */ + +import path from 'path'; +import type { Feature, FeatureStatusWithPipeline } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; +import { + createLogger, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, +} from '@automaker/utils'; +import { + getFeatureDir, + getFeaturesDir, + getExecutionStatePath, + ensureAutomakerDir, +} from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; +import type { SettingsService } from './settings-service.js'; +import type { PipelineStatusInfo } from './pipeline-orchestrator.js'; + +const logger = createLogger('RecoveryService'); + +export interface ExecutionState { + version: 1; + autoLoopWasRunning: boolean; + maxConcurrency: number; + projectPath: string; + branchName: string | null; + runningFeatureIds: string[]; + savedAt: string; +} + +export const DEFAULT_EXECUTION_STATE: ExecutionState = { + version: 1, + autoLoopWasRunning: false, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + projectPath: '', + branchName: null, + runningFeatureIds: [], + savedAt: '', +}; + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } +) => Promise; +export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise; +export type DetectPipelineStatusFn = ( + projectPath: string, + featureId: string, + status: FeatureStatusWithPipeline +) => Promise; +export type ResumePipelineFn = ( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo +) => Promise; +export type IsFeatureRunningFn = (featureId: string) => boolean; +export type AcquireRunningFeatureFn = (options: { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; +}) => RunningFeature; +export type ReleaseRunningFeatureFn = (featureId: string) => void; + +export class RecoveryService { + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private executeFeatureFn: ExecuteFeatureFn, + private loadFeatureFn: LoadFeatureFn, + private detectPipelineStatusFn: DetectPipelineStatusFn, + private resumePipelineFn: ResumePipelineFn, + private isFeatureRunningFn: IsFeatureRunningFn, + private acquireRunningFeatureFn: AcquireRunningFeatureFn, + private releaseRunningFeatureFn: ReleaseRunningFeatureFn + ) {} + + async saveExecutionStateForProject( + projectPath: string, + branchName: string | null, + maxConcurrency: number + ): Promise { + try { + await ensureAutomakerDir(projectPath); + const runningFeatureIds = this.concurrencyManager + .getAllRunning() + .filter((f) => f.projectPath === projectPath) + .map((f) => f.featureId); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency, + projectPath, + branchName, + runningFeatureIds, + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile( + getExecutionStatePath(projectPath), + JSON.stringify(state, null, 2), + 'utf-8' + ); + } catch { + /* ignore */ + } + } + + async saveExecutionState( + projectPath: string, + autoLoopWasRunning = false, + maxConcurrency = DEFAULT_MAX_CONCURRENCY + ): Promise { + try { + await ensureAutomakerDir(projectPath); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning, + maxConcurrency, + projectPath, + branchName: null, + runningFeatureIds: this.concurrencyManager.getAllRunning().map((rf) => rf.featureId), + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile( + getExecutionStatePath(projectPath), + JSON.stringify(state, null, 2), + 'utf-8' + ); + } catch { + /* ignore */ + } + } + + async loadExecutionState(projectPath: string): Promise { + try { + const content = (await secureFs.readFile( + getExecutionStatePath(projectPath), + 'utf-8' + )) as string; + return JSON.parse(content) as ExecutionState; + } catch { + return DEFAULT_EXECUTION_STATE; + } + } + + async clearExecutionState(projectPath: string, _branchName: string | null = null): Promise { + try { + await secureFs.unlink(getExecutionStatePath(projectPath)); + } catch { + /* ignore */ + } + } + + async contextExists(projectPath: string, featureId: string): Promise { + try { + await secureFs.access(path.join(getFeatureDir(projectPath, featureId), 'agent-output.md')); + return true; + } catch { + return false; + } + } + + private async executeFeatureWithContext( + projectPath: string, + featureId: string, + context: string, + useWorktrees: boolean + ): Promise { + const feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const prompts = await getPromptCustomization(this.settingsService, '[RecoveryService]'); + const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; + let prompt = prompts.taskExecution.resumeFeatureTemplate; + prompt = prompt + .replace(/\{\{featurePrompt\}\}/g, featurePrompt) + .replace(/\{\{previousContext\}\}/g, context); + return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + continuationPrompt: prompt, + _calledInternally: true, + }); + } + + async resumeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + _calledInternally = false + ): Promise { + if (!_calledInternally && this.isFeatureRunningFn(featureId)) return; + this.acquireRunningFeatureFn({ + featureId, + projectPath, + isAutoMode: false, + allowReuse: _calledInternally, + }); + try { + const feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const pipelineInfo = await this.detectPipelineStatusFn( + projectPath, + featureId, + (feature.status || '') as FeatureStatusWithPipeline + ); + if (pipelineInfo.isPipeline) + return await this.resumePipelineFn(projectPath, feature, useWorktrees, pipelineInfo); + const hasContext = await this.contextExists(projectPath, featureId); + if (hasContext) { + const context = (await secureFs.readFile( + path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'), + 'utf-8' + )) as string; + this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', { + featureId, + featureName: feature.title, + projectPath, + hasContext: true, + message: `Resuming feature "${feature.title}" from saved context`, + }); + return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + } + this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', { + featureId, + featureName: feature.title, + projectPath, + hasContext: false, + message: `Starting fresh execution for interrupted feature "${feature.title}"`, + }); + return await this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); + } finally { + this.releaseRunningFeatureFn(featureId); + } + } + + async resumeInterruptedFeatures(projectPath: string): Promise { + const featuresDir = getFeaturesDir(projectPath); + try { + // Load execution state to find features that were running before restart. + // This is critical because reconcileAllFeatureStates() runs at server startup + // and resets in_progress/interrupted/pipeline_* features to ready/backlog + // BEFORE the UI connects and calls this method. Without checking execution state, + // we would find no features to resume since their statuses have already been reset. + const executionState = await this.loadExecutionState(projectPath); + const previouslyRunningIds = new Set(executionState.runningFeatureIds ?? []); + + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + const featuresWithContext: Feature[] = []; + const featuresWithoutContext: Feature[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const result = await readJsonWithRecovery( + path.join(featuresDir, entry.name, 'feature.json'), + null, + { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true } + ); + logRecoveryWarning(result, `Feature ${entry.name}`, logger); + const feature = result.data; + if (!feature) continue; + + // Check if the feature should be resumed: + // 1. Features still in active states (in_progress, pipeline_*) - not yet reconciled + // 2. Features in interrupted state - explicitly marked for resume + // 3. Features that were previously running (from execution state) and are now + // in ready/backlog due to reconciliation resetting their status + const isActiveState = + feature.status === 'in_progress' || + feature.status === 'interrupted' || + (feature.status && feature.status.startsWith('pipeline_')); + const wasReconciledFromRunning = + previouslyRunningIds.has(feature.id) && + (feature.status === 'ready' || feature.status === 'backlog'); + + if (isActiveState || wasReconciledFromRunning) { + if (await this.contextExists(projectPath, feature.id)) { + featuresWithContext.push(feature); + } else { + featuresWithoutContext.push(feature); + } + } + } + } + const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext]; + if (allInterruptedFeatures.length === 0) return; + + logger.info( + `[resumeInterruptedFeatures] Found ${allInterruptedFeatures.length} feature(s) to resume ` + + `(${previouslyRunningIds.size} from execution state, statuses: ${allInterruptedFeatures.map((f) => `${f.id}=${f.status}`).join(', ')})` + ); + + this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', { + message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`, + projectPath, + featureIds: allInterruptedFeatures.map((f) => f.id), + features: allInterruptedFeatures.map((f) => ({ + id: f.id, + title: f.title, + status: f.status, + branchName: f.branchName ?? null, + hasContext: featuresWithContext.some((fc) => fc.id === f.id), + })), + }); + for (const feature of allInterruptedFeatures) { + try { + if (!this.isFeatureRunningFn(feature.id)) + await this.resumeFeature(projectPath, feature.id, true); + } catch { + /* continue */ + } + } + + // Clear execution state after successful resume to prevent + // re-resuming the same features on subsequent calls + await this.clearExecutionState(projectPath); + } catch { + /* ignore */ + } + } +} diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6ffdd488..7b3ffa70 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -31,6 +31,7 @@ import type { WorktreeInfo, PhaseModelConfig, PhaseModelEntry, + FeatureTemplate, ClaudeApiProfile, ClaudeCompatibleProvider, ProviderModel, @@ -40,6 +41,7 @@ import { DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, @@ -139,6 +141,11 @@ export class SettingsService { // Migrate model IDs to canonical format const migratedModelSettings = this.migrateModelSettings(settings); + // Merge built-in feature templates: ensure all built-in templates exist in user settings. + // User customizations (enabled/disabled state, order overrides) are preserved. + // New built-in templates added in code updates are injected for existing users. + const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates); + // Apply any missing defaults (for backwards compatibility) let result: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, @@ -149,6 +156,7 @@ export class SettingsService { ...settings.keyboardShortcuts, }, phaseModels: migratedPhaseModels, + featureTemplates: mergedFeatureTemplates, }; // Version-based migrations @@ -250,6 +258,32 @@ export class SettingsService { return result; } + /** + * Merge built-in feature templates with user's stored templates. + * + * Ensures new built-in templates added in code updates are available to existing users + * without overwriting their customizations (e.g., enabled/disabled state, custom order). + * Built-in templates missing from stored settings are appended with their defaults. + * + * @param storedTemplates - Templates from user's settings file (may be undefined for new installs) + * @returns Merged template list with all built-in templates present + */ + private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] { + if (!storedTemplates) { + return DEFAULT_FEATURE_TEMPLATES; + } + + const storedIds = new Set(storedTemplates.map((t) => t.id)); + const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id)); + + if (missingBuiltIns.length === 0) { + return storedTemplates; + } + + // Append missing built-in templates after existing ones + return [...storedTemplates, ...missingBuiltIns]; + } + /** * Migrate legacy enhancementModel/validationModel fields to phaseModels structure * @@ -573,6 +607,17 @@ export class SettingsService { ignoreEmptyArrayOverwrite('claudeApiProfiles'); // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers + // Check for explicit permission to clear eventHooks (escape hatch for intentional clearing) + const allowEmptyEventHooks = + (sanitizedUpdates as Record).__allowEmptyEventHooks === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyEventHooks; + + // Only guard eventHooks if explicit permission wasn't granted + if (!allowEmptyEventHooks) { + ignoreEmptyArrayOverwrite('eventHooks'); + } + // Empty object overwrite guard const ignoreEmptyObjectOverwrite = (key: K): void => { const nextVal = sanitizedUpdates[key] as unknown; @@ -729,6 +774,7 @@ export class SettingsService { anthropic: { configured: boolean; masked: string }; google: { configured: boolean; masked: string }; openai: { configured: boolean; masked: string }; + zai: { configured: boolean; masked: string }; }> { const credentials = await this.getCredentials(); @@ -750,6 +796,10 @@ export class SettingsService { configured: !!credentials.apiKeys.openai, masked: maskKey(credentials.apiKeys.openai), }, + zai: { + configured: !!credentials.apiKeys.zai, + masked: maskKey(credentials.apiKeys.zai), + }, }; } @@ -1018,6 +1068,7 @@ export class SettingsService { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: '', }, }); migratedCredentials = true; diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts new file mode 100644 index 00000000..cd1c8050 --- /dev/null +++ b/apps/server/src/services/spec-parser.ts @@ -0,0 +1,227 @@ +/** + * Spec Parser - Pure functions for parsing spec content and detecting markers + * + * Extracts tasks from generated specs, detects progress markers, + * and extracts summary content from various formats. + */ + +import type { ParsedTask } from '@automaker/types'; + +/** + * Parse a single task line + * Format: - [ ] T###: Description | File: path/to/file + */ +function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // Match pattern: - [ ] T###: Description | File: path + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); + if (!taskMatch) { + // Try simpler pattern without file + const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); + if (simpleMatch) { + return { + id: simpleMatch[1], + description: simpleMatch[2].trim(), + phase: currentPhase, + status: 'pending', + }; + } + return null; + } + + return { + id: taskMatch[1], + description: taskMatch[2].trim(), + filePath: taskMatch[3]?.trim(), + phase: currentPhase, + status: 'pending', + }; +} + +/** + * Parse tasks from generated spec content + * Looks for the ```tasks code block and extracts task lines + * Format: - [ ] T###: Description | File: path/to/file + */ +export function parseTasksFromSpec(specContent: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + // Extract content within ```tasks ... ``` block + const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); + if (!tasksBlockMatch) { + // Try fallback: look for task lines anywhere in content + const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); + if (!taskLines) { + return tasks; + } + // Parse fallback task lines + let currentPhase: string | undefined; + for (const line of taskLines) { + const parsed = parseTaskLine(line, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + return tasks; + } + + const tasksContent = tasksBlockMatch[1]; + const lines = tasksContent.split('\n'); + + let currentPhase: string | undefined; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for phase header (e.g., "## Phase 1: Foundation") + const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); + if (phaseMatch) { + currentPhase = phaseMatch[1].trim(); + continue; + } + + // Check for task line + if (trimmedLine.startsWith('- [ ]')) { + const parsed = parseTaskLine(trimmedLine, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + } + + return tasks; +} + +/** + * Detect [TASK_START] marker in text and extract task ID + * Format: [TASK_START] T###: Description + */ +export function detectTaskStartMarker(text: string): string | null { + const match = text.match(/\[TASK_START\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [TASK_COMPLETE] marker in text and extract task ID + * Format: [TASK_COMPLETE] T###: Brief summary + */ +export function detectTaskCompleteMarker(text: string): string | null { + const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); + return match ? match[1] : null; +} + +/** + * Detect [PHASE_COMPLETE] marker in text and extract phase number + * Format: [PHASE_COMPLETE] Phase N complete + */ +export function detectPhaseCompleteMarker(text: string): number | null { + const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Fallback spec detection when [SPEC_GENERATED] marker is missing + * Looks for structural elements that indicate a spec was generated. + * This is especially important for non-Claude models that may not output + * the explicit [SPEC_GENERATED] marker. + * + * @param text - The text content to check for spec structure + * @returns true if the text appears to be a generated spec + */ +export function detectSpecFallback(text: string): boolean { + // Check for key structural elements of a spec + const hasTasksBlock = /```tasks[\s\S]*```/.test(text); + const hasTaskLines = /- \[ \] T\d{3}:/.test(text); + + // Check for common spec sections (case-insensitive) + const hasAcceptanceCriteria = /acceptance criteria/i.test(text); + const hasTechnicalContext = /technical context/i.test(text); + const hasProblemStatement = /problem statement/i.test(text); + const hasUserStory = /user story/i.test(text); + // Additional patterns for different model outputs + const hasGoal = /\*\*Goal\*\*:/i.test(text); + const hasSolution = /\*\*Solution\*\*:/i.test(text); + const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); + const hasOverview = /##\s*(overview|summary)/i.test(text); + + // Spec is detected if we have task structure AND at least some spec content + const hasTaskStructure = hasTasksBlock || hasTaskLines; + const hasSpecContent = + hasAcceptanceCriteria || + hasTechnicalContext || + hasProblemStatement || + hasUserStory || + hasGoal || + hasSolution || + hasImplementation || + hasOverview; + + return hasTaskStructure && hasSpecContent; +} + +/** + * Extract summary from text content + * Checks for multiple formats in order of priority: + * 1. Explicit tags + * 2. ## Summary section (markdown) + * 3. **Goal**: section (lite planning mode) + * 4. **Problem**: or **Problem Statement**: section (spec/full modes) + * 5. **Solution**: section as fallback + * + * Note: Uses last match for each pattern to avoid stale summaries + * when agent output accumulates across multiple runs. + * + * @param text - The text content to extract summary from + * @returns The extracted summary string, or null if no summary found + */ +export function extractSummary(text: string): string | null { + // Helper to truncate content to first paragraph with max length + const truncate = (content: string, maxLength: number): string => { + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; + }; + + // Helper to get last match from matchAll results + const getLastMatch = (matches: IterableIterator): RegExpMatchArray | null => { + const arr = [...matches]; + return arr.length > 0 ? arr[arr.length - 1] : null; + }; + + // Check for explicit tags first (use last match to avoid stale summaries) + const summaryMatches = text.matchAll(/([\s\S]*?)<\/summary>/g); + const summaryMatch = getLastMatch(summaryMatches); + if (summaryMatch) { + return summaryMatch[1].trim(); + } + + // Check for ## Summary section (use last match) + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); + const sectionMatch = getLastMatch(sectionMatches); + if (sectionMatch) { + return truncate(sectionMatch[1].trim(), 500); + } + + // Check for **Goal**: section (lite mode, use last match) + const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); + const goalMatch = getLastMatch(goalMatches); + if (goalMatch) { + return goalMatch[1].trim(); + } + + // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) + const problemMatches = text.matchAll( + /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi + ); + const problemMatch = getLastMatch(problemMatches); + if (problemMatch) { + return truncate(problemMatch[1].trim(), 500); + } + + // Check for **Solution**: section as fallback (use last match) + const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); + const solutionMatch = getLastMatch(solutionMatches); + if (solutionMatch) { + return truncate(solutionMatch[1].trim(), 300); + } + + return null; +} diff --git a/apps/server/src/services/stage-files-service.ts b/apps/server/src/services/stage-files-service.ts new file mode 100644 index 00000000..e155b3ee --- /dev/null +++ b/apps/server/src/services/stage-files-service.ts @@ -0,0 +1,117 @@ +/** + * stageFilesService - Path validation and git staging/unstaging operations + * + * Extracted from createStageFilesHandler to centralise path canonicalization, + * path-traversal validation, and git invocation so they can be tested and + * reused independently of the HTTP layer. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { execGitCommand } from '../lib/git.js'; + +/** + * Result returned by `stageFiles` on success. + */ +export interface StageFilesResult { + operation: string; + filesCount: number; +} + +/** + * Error thrown when one or more file paths fail validation (e.g. absolute + * paths, path-traversal attempts, or paths that resolve outside the worktree + * root, or when the worktree path itself does not exist). + * + * Handlers can catch this to return an HTTP 400 response instead of 500. + */ +export class StageFilesValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'StageFilesValidationError'; + } +} + +/** + * Resolve the canonical path of the worktree root, validate every file path + * against it to prevent path-traversal attacks, and then invoke the + * appropriate git command (`add` or `reset`) to stage or unstage the files. + * + * @param worktreePath - Absolute path to the git worktree root directory. + * @param files - Relative file paths to stage or unstage. + * @param operation - `'stage'` runs `git add`, `'unstage'` runs `git reset HEAD`. + * + * @returns An object containing the operation name and the number of files + * that were staged/unstaged. + * + * @throws {StageFilesValidationError} When `worktreePath` is inaccessible or + * any entry in `files` fails the path-traversal checks. + * @throws {Error} When the underlying git command fails. + */ +export async function stageFiles( + worktreePath: string, + files: string[], + operation: 'stage' | 'unstage' +): Promise { + // Canonicalize the worktree root by resolving symlinks so that + // path-traversal checks are reliable even when symlinks are involved. + let canonicalRoot: string; + try { + canonicalRoot = await fs.realpath(worktreePath); + } catch { + throw new StageFilesValidationError('worktreePath does not exist or is not accessible'); + } + + // Validate and sanitize each file path to prevent path traversal attacks. + // Each file entry is resolved against the canonicalized worktree root and + // must remain within that root directory. + const base = canonicalRoot + path.sep; + const sanitizedFiles: string[] = []; + for (const file of files) { + // Reject empty or whitespace-only paths — path.resolve(canonicalRoot, '') + // returns canonicalRoot itself, so without this guard an empty string would + // pass all subsequent checks and be forwarded to git unchanged. + if (file.trim() === '') { + throw new StageFilesValidationError( + 'Invalid file path (empty or whitespace-only paths not allowed)' + ); + } + // Reject absolute paths + if (path.isAbsolute(file)) { + throw new StageFilesValidationError( + `Invalid file path (absolute paths not allowed): ${file}` + ); + } + // Reject entries containing '..' + if (file.includes('..')) { + throw new StageFilesValidationError( + `Invalid file path (path traversal not allowed): ${file}` + ); + } + // Resolve the file path against the canonicalized worktree root and + // ensure the result stays within the worktree directory. + const resolved = path.resolve(canonicalRoot, file); + if (resolved !== canonicalRoot && !resolved.startsWith(base)) { + throw new StageFilesValidationError( + `Invalid file path (outside worktree directory): ${file}` + ); + } + // Forward only the original relative path to git — git interprets + // paths relative to its working directory (canonicalRoot / worktreePath), + // so we do not need to pass the resolved absolute path. + sanitizedFiles.push(file); + } + + if (operation === 'stage') { + // Stage the specified files + await execGitCommand(['add', '--', ...sanitizedFiles], worktreePath); + } else { + // Unstage the specified files + await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], worktreePath); + } + + return { + operation, + filesCount: sanitizedFiles.length, + }; +} diff --git a/apps/server/src/services/stash-service.ts b/apps/server/src/services/stash-service.ts new file mode 100644 index 00000000..dd0a8737 --- /dev/null +++ b/apps/server/src/services/stash-service.ts @@ -0,0 +1,461 @@ +/** + * StashService - Stash operations without HTTP + * + * Encapsulates stash workflows including: + * - Push (create) stashes with optional message and file selection + * - List all stash entries with metadata and changed files + * - Apply or pop a stash entry with conflict detection + * - Drop (delete) a stash entry + * - Conflict detection from command output and git diff + * - Lifecycle event emission (start, progress, conflicts, success, failure) + * + * Extracted from the worktree stash route handlers to improve organisation + * and testability. Follows the same pattern as pull-service.ts and + * merge-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; + +const logger = createLogger('StashService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface StashApplyOptions { + /** When true, remove the stash entry after applying (git stash pop) */ + pop?: boolean; +} + +export interface StashApplyResult { + success: boolean; + error?: string; + applied?: boolean; + hasConflicts?: boolean; + conflictFiles?: string[]; + operation?: 'apply' | 'pop'; + stashIndex?: number; + message?: string; +} + +export interface StashPushResult { + success: boolean; + error?: string; + stashed: boolean; + branch?: string; + message?: string; +} + +export interface StashEntry { + index: number; + message: string; + branch: string; + date: string; + files: string[]; +} + +export interface StashListResult { + success: boolean; + error?: string; + stashes: StashEntry[]; + total: number; +} + +export interface StashDropResult { + success: boolean; + error?: string; + dropped: boolean; + stashIndex?: number; + message?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Retrieve the list of files with unmerged (conflicted) entries using git diff. + * + * @param worktreePath - Path to the git worktree + * @returns Array of file paths that have unresolved conflicts + */ +export async function getConflictedFiles(worktreePath: string): Promise { + try { + const diffOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + worktreePath + ); + return diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // If we cannot get the file list, return an empty array + return []; + } +} + +/** + * Determine whether command output indicates a merge conflict. + */ +function isConflictOutput(output: string): boolean { + return output.includes('CONFLICT') || output.includes('Merge conflict'); +} + +/** + * Build a conflict result from stash apply/pop, emit events, and return. + * Extracted to avoid duplicating conflict handling in the try and catch paths. + */ +async function handleStashConflicts( + worktreePath: string, + stashIndex: number, + operation: 'apply' | 'pop', + events?: EventEmitter +): Promise { + const conflictFiles = await getConflictedFiles(worktreePath); + + events?.emit('stash:conflicts', { + worktreePath, + stashIndex, + operation, + conflictFiles, + }); + + const result: StashApplyResult = { + success: true, + applied: true, + hasConflicts: true, + conflictFiles, + operation, + stashIndex, + message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`, + }; + + events?.emit('stash:success', { + worktreePath, + stashIndex, + operation, + hasConflicts: true, + conflictFiles, + }); + + return result; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Apply or pop a stash entry in the given worktree. + * + * The workflow: + * 1. Validate inputs + * 2. Emit stash:start event + * 3. Run `git stash apply` or `git stash pop` + * 4. Emit stash:progress event with raw command output + * 5. Check output for conflict markers; if conflicts found, collect files and + * emit stash:conflicts event + * 6. Emit stash:success or stash:failure depending on outcome + * 7. Return a structured StashApplyResult + * + * @param worktreePath - Absolute path to the git worktree + * @param stashIndex - Zero-based stash index (stash@{N}) + * @param options - Optional flags (pop) + * @returns StashApplyResult with detailed status information + */ +export async function applyOrPop( + worktreePath: string, + stashIndex: number, + options?: StashApplyOptions, + events?: EventEmitter +): Promise { + const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply'; + const stashRef = `stash@{${stashIndex}}`; + + logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`); + + // 1. Emit start event + events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation }); + + try { + // 2. Run git stash apply / pop + let stdout = ''; + + try { + stdout = await execGitCommand(['stash', operation, stashRef], worktreePath); + } catch (gitError: unknown) { + const err = gitError as { stdout?: string; stderr?: string; message?: string }; + const errStdout = err.stdout || ''; + const errStderr = err.stderr || err.message || ''; + + const combinedOutput = `${errStdout}\n${errStderr}`; + + // 3. Emit progress with raw output + events?.emit('stash:progress', { + worktreePath, + stashIndex, + operation, + output: combinedOutput, + }); + + // 4. Check if the error is a conflict + if (isConflictOutput(combinedOutput)) { + return handleStashConflicts(worktreePath, stashIndex, operation, events); + } + + // 5. Non-conflict git error – re-throw so the outer catch logs and handles it + throw gitError; + } + + // 6. Command succeeded – check stdout for conflict markers (some git versions + // exit 0 even when conflicts occur during apply) + const combinedOutput = stdout; + + events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput }); + + if (isConflictOutput(combinedOutput)) { + return handleStashConflicts(worktreePath, stashIndex, operation, events); + } + + // 7. Clean success + const result: StashApplyResult = { + success: true, + applied: true, + hasConflicts: false, + operation, + stashIndex, + message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`, + }; + + events?.emit('stash:success', { + worktreePath, + stashIndex, + operation, + hasConflicts: false, + }); + + return result; + } catch (error) { + const errorMessage = getErrorMessage(error); + + logger.error(`Stash ${operation} failed`, { error: getErrorMessage(error) }); + + events?.emit('stash:failure', { + worktreePath, + stashIndex, + operation, + error: errorMessage, + }); + + return { + success: false, + error: errorMessage, + applied: false, + operation, + stashIndex, + }; + } +} + +// ============================================================================ +// Push Stash +// ============================================================================ + +/** + * Stash uncommitted changes (including untracked files) with an optional + * message and optional file selection. + * + * Workflow: + * 1. Check for uncommitted changes via `git status --porcelain` + * 2. If no changes, return early with stashed: false + * 3. Build and run `git stash push --include-untracked [-m message] [-- files]` + * 4. Retrieve the current branch name + * 5. Return a structured StashPushResult + * + * @param worktreePath - Absolute path to the git worktree + * @param options - Optional message and files to selectively stash + * @returns StashPushResult with stash status and branch info + */ +export async function pushStash( + worktreePath: string, + options?: { message?: string; files?: string[] }, + events?: EventEmitter +): Promise { + const message = options?.message; + const files = options?.files; + + logger.info(`[StashService] push stash in ${worktreePath}`); + events?.emit('stash:start', { worktreePath, operation: 'push' }); + + // 1. Check for any changes to stash + const status = await execGitCommand(['status', '--porcelain'], worktreePath); + + if (!status.trim()) { + events?.emit('stash:success', { worktreePath, operation: 'push', stashed: false }); + return { + success: true, + stashed: false, + message: 'No changes to stash', + }; + } + + // 2. Build stash push command args + const args = ['stash', 'push', '--include-untracked']; + if (message && message.trim()) { + args.push('-m', message.trim()); + } + + // If specific files are provided, add them as pathspecs after '--' + if (files && files.length > 0) { + args.push('--'); + args.push(...files); + } + + // 3. Execute stash push (with automatic index.lock cleanup and retry) + await execGitCommandWithLockRetry(args, worktreePath); + + // 4. Get current branch name + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const branchName = branchOutput.trim(); + + events?.emit('stash:success', { + worktreePath, + operation: 'push', + stashed: true, + branch: branchName, + }); + + return { + success: true, + stashed: true, + branch: branchName, + message: message?.trim() || `WIP on ${branchName}`, + }; +} + +// ============================================================================ +// List Stashes +// ============================================================================ + +/** + * List all stash entries for a worktree with metadata and changed files. + * + * Workflow: + * 1. Run `git stash list` with a custom format to get index, message, and date + * 2. Parse each stash line into a structured StashEntry + * 3. For each entry, fetch the list of files changed via `git stash show` + * 4. Return the full list as a StashListResult + * + * @param worktreePath - Absolute path to the git worktree + * @returns StashListResult with all stash entries and their metadata + */ +export async function listStash(worktreePath: string): Promise { + logger.info(`[StashService] list stashes in ${worktreePath}`); + + // 1. Get stash list with format: index, message, date + // Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility + const stashOutput = await execGitCommand( + ['stash', 'list', '--format=%gd|||%s|||%aI'], + worktreePath + ); + + if (!stashOutput.trim()) { + return { + success: true, + stashes: [], + total: 0, + }; + } + + const stashLines = stashOutput + .trim() + .split('\n') + .filter((l) => l.trim()); + const stashes: StashEntry[] = []; + + for (const line of stashLines) { + const parts = line.split('|||'); + if (parts.length < 3) continue; + + const refSpec = parts[0].trim(); // e.g., "stash@{0}" + const stashMessage = parts[1].trim(); + const date = parts[2].trim(); + + // Extract index from stash@{N}; skip entries that don't match the expected format + const indexMatch = refSpec.match(/stash@\{(\d+)\}/); + if (!indexMatch) continue; + const index = parseInt(indexMatch[1], 10); + + // Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message") + let branch = ''; + const branchMatch = stashMessage.match(/^(?:WIP on|On) ([^:]+):/); + if (branchMatch) { + branch = branchMatch[1]; + } + + // Get list of files in this stash + let files: string[] = []; + try { + const filesOutput = await execGitCommand( + ['stash', 'show', refSpec, '--name-only'], + worktreePath + ); + files = filesOutput + .trim() + .split('\n') + .filter((f) => f.trim()); + } catch { + // Ignore errors getting file list + } + + stashes.push({ + index, + message: stashMessage, + branch, + date, + files, + }); + } + + return { + success: true, + stashes, + total: stashes.length, + }; +} + +// ============================================================================ +// Drop Stash +// ============================================================================ + +/** + * Drop (delete) a stash entry by index. + * + * @param worktreePath - Absolute path to the git worktree + * @param stashIndex - Zero-based stash index (stash@{N}) + * @returns StashDropResult with drop status + */ +export async function dropStash( + worktreePath: string, + stashIndex: number, + events?: EventEmitter +): Promise { + const stashRef = `stash@{${stashIndex}}`; + + logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`); + events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation: 'drop' }); + + await execGitCommand(['stash', 'drop', stashRef], worktreePath); + + events?.emit('stash:success', { worktreePath, stashIndex, stashRef, operation: 'drop' }); + + return { + success: true, + dropped: true, + stashIndex, + message: `Stash ${stashRef} dropped successfully`, + }; +} diff --git a/apps/server/src/services/sync-service.ts b/apps/server/src/services/sync-service.ts new file mode 100644 index 00000000..f47055c9 --- /dev/null +++ b/apps/server/src/services/sync-service.ts @@ -0,0 +1,209 @@ +/** + * SyncService - Pull then push in a single operation + * + * Composes performPull() and performPush() to synchronize a branch + * with its remote. Always uses stashIfNeeded for the pull step. + * If push fails with divergence after pull, retries once. + * + * Follows the same pattern as pull-service.ts and push-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { performPull } from './pull-service.js'; +import { performPush } from './push-service.js'; +import type { PullResult } from './pull-service.js'; +import type { PushResult } from './push-service.js'; + +const logger = createLogger('SyncService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface SyncOptions { + /** Remote name (defaults to 'origin') */ + remote?: string; +} + +export interface SyncResult { + success: boolean; + error?: string; + branch?: string; + /** Whether the pull step was performed */ + pulled?: boolean; + /** Whether the push step was performed */ + pushed?: boolean; + /** Pull resulted in conflicts */ + hasConflicts?: boolean; + /** Files with merge conflicts */ + conflictFiles?: string[]; + /** Source of conflicts ('pull' | 'stash') */ + conflictSource?: 'pull' | 'stash'; + /** Whether the pull was a fast-forward */ + isFastForward?: boolean; + /** Whether the pull resulted in a merge commit */ + isMerge?: boolean; + /** Whether push divergence was auto-resolved */ + autoResolved?: boolean; + message?: string; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a sync operation (pull then push) on the given worktree. + * + * The workflow: + * 1. Pull from remote with stashIfNeeded: true + * 2. If pull has conflicts, stop and return conflict info + * 3. Push to remote + * 4. If push fails with divergence after pull, retry once + * + * @param worktreePath - Path to the git worktree + * @param options - Sync options (remote) + * @returns SyncResult with detailed status information + */ +export async function performSync( + worktreePath: string, + options?: SyncOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + + // 1. Pull from remote + logger.info('Sync: starting pull', { worktreePath, remote: targetRemote }); + + let pullResult: PullResult; + try { + pullResult = await performPull(worktreePath, { + remote: targetRemote, + stashIfNeeded: true, + }); + } catch (pullError) { + return { + success: false, + error: `Sync pull failed: ${getErrorMessage(pullError)}`, + }; + } + + if (!pullResult.success) { + return { + success: false, + branch: pullResult.branch, + pulled: false, + pushed: false, + error: `Sync pull failed: ${pullResult.error}`, + hasConflicts: pullResult.hasConflicts, + conflictFiles: pullResult.conflictFiles, + conflictSource: pullResult.conflictSource, + }; + } + + // 2. If pull had conflicts, stop and return conflict info + if (pullResult.hasConflicts) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + hasConflicts: true, + conflictFiles: pullResult.conflictFiles, + conflictSource: pullResult.conflictSource, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.', + message: pullResult.message, + }; + } + + // 3. Push to remote + logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote }); + + let pushResult: PushResult; + try { + pushResult = await performPush(worktreePath, { + remote: targetRemote, + }); + } catch (pushError) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: `Sync push failed: ${getErrorMessage(pushError)}`, + }; + } + + if (!pushResult.success) { + // 4. If push diverged after pull, retry once with autoResolve + if (pushResult.diverged) { + logger.info('Sync: push diverged after pull, retrying with autoResolve', { + worktreePath, + remote: targetRemote, + }); + + try { + const retryResult = await performPush(worktreePath, { + remote: targetRemote, + autoResolve: true, + }); + + if (retryResult.success) { + return { + success: true, + branch: retryResult.branch, + pulled: true, + pushed: true, + autoResolved: true, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + message: 'Sync completed (push required auto-resolve).', + }; + } + + return { + success: false, + branch: retryResult.branch, + pulled: true, + pushed: false, + hasConflicts: retryResult.hasConflicts, + conflictFiles: retryResult.conflictFiles, + error: retryResult.error, + }; + } catch (retryError) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + error: `Sync push retry failed: ${getErrorMessage(retryError)}`, + }; + } + } + + return { + success: false, + branch: pushResult.branch, + pulled: true, + pushed: false, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: `Sync push failed: ${pushResult.error}`, + }; + } + + return { + success: true, + branch: pushResult.branch, + pulled: pullResult.pulled ?? true, + pushed: true, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + message: pullResult.pulled + ? 'Sync completed: pulled latest changes and pushed.' + : 'Sync completed: already up to date, pushed local commits.', + }; +} diff --git a/apps/server/src/services/typed-event-bus.ts b/apps/server/src/services/typed-event-bus.ts new file mode 100644 index 00000000..09d1e9bc --- /dev/null +++ b/apps/server/src/services/typed-event-bus.ts @@ -0,0 +1,112 @@ +/** + * TypedEventBus - Type-safe event emission wrapper for AutoModeService + * + * This class wraps the existing EventEmitter to provide type-safe event emission, + * specifically encapsulating the `emitAutoModeEvent` pattern used throughout AutoModeService. + * + * Key behavior: + * - emitAutoModeEvent wraps events in 'auto-mode:event' format for frontend consumption + * - Preserves all existing event emission patterns for backward compatibility + * - Frontend receives events in the exact same format as before (no breaking changes) + */ + +import type { EventEmitter, EventType, EventCallback } from '../lib/events.js'; + +/** + * Auto-mode event types that can be emitted through the TypedEventBus. + * These correspond to the event types expected by the frontend. + */ +export type AutoModeEventType = + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'auto_mode_idle' + | 'auto_mode_error' + | 'auto_mode_paused_failures' + | 'auto_mode_feature_start' + | 'auto_mode_feature_complete' + | 'auto_mode_feature_resuming' + | 'auto_mode_progress' + | 'auto_mode_tool' + | 'auto_mode_task_started' + | 'auto_mode_task_complete' + | 'auto_mode_task_status' + | 'auto_mode_phase_complete' + | 'auto_mode_summary' + | 'auto_mode_resuming_features' + | 'planning_started' + | 'plan_approval_required' + | 'plan_approved' + | 'plan_auto_approved' + | 'plan_rejected' + | 'plan_revision_requested' + | 'plan_revision_warning' + | 'plan_spec_updated' + | 'pipeline_step_started' + | 'pipeline_step_complete' + | 'pipeline_test_failed' + | 'pipeline_merge_conflict' + | 'feature_status_changed' + | 'features_reconciled'; + +/** + * TypedEventBus wraps an EventEmitter to provide type-safe event emission + * with the auto-mode event wrapping pattern. + */ +export class TypedEventBus { + private events: EventEmitter; + + /** + * Create a TypedEventBus wrapping an existing EventEmitter. + * @param events - The underlying EventEmitter to wrap + */ + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Emit a raw event directly to subscribers. + * Use this for non-auto-mode events that don't need wrapping. + * @param type - The event type + * @param payload - The event payload + */ + emit(type: EventType, payload: unknown): void { + this.events.emit(type, payload); + } + + /** + * Emit an auto-mode event wrapped in the correct format for the client. + * All auto-mode events are sent as type "auto-mode:event" with the actual + * event type and data in the payload. + * + * This produces the exact same event format that the frontend expects: + * { type: eventType, ...data } + * + * @param eventType - The auto-mode event type (e.g., 'auto_mode_started') + * @param data - Additional data to include in the event payload + */ + emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit('auto-mode:event', { + type: eventType, + ...data, + }); + } + + /** + * Subscribe to all events from the underlying emitter. + * @param callback - Function called with (type, payload) for each event + * @returns Unsubscribe function + */ + subscribe(callback: EventCallback): () => void { + return this.events.subscribe(callback); + } + + /** + * Get the underlying EventEmitter for cases where direct access is needed. + * Use sparingly - prefer the typed methods when possible. + * @returns The wrapped EventEmitter + */ + getUnderlyingEmitter(): EventEmitter { + return this.events; + } +} diff --git a/apps/server/src/services/worktree-branch-service.ts b/apps/server/src/services/worktree-branch-service.ts new file mode 100644 index 00000000..21134212 --- /dev/null +++ b/apps/server/src/services/worktree-branch-service.ts @@ -0,0 +1,406 @@ +/** + * WorktreeBranchService - Switch branch operations without HTTP + * + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Fetches the latest remote refs before switching to ensure remote branch + * references are up-to-date for accurate detection and checkout. + * + * Extracted from the worktree switch-branch route to improve organization + * and testability. Follows the same pattern as pull-service.ts and + * rebase-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; +import type { EventEmitter } from '../lib/events.js'; +import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js'; + +const logger = createLogger('WorktreeBranchService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface SwitchBranchResult { + success: boolean; + error?: string; + result?: { + previousBranch: string; + currentBranch: string; + message: string; + hasConflicts?: boolean; + stashedChanges?: boolean; + }; + /** Set when checkout fails and stash pop produced conflicts during recovery */ + stashPopConflicts?: boolean; + /** Human-readable message when stash pop conflicts occur during error recovery */ + stashPopConflictMessage?: string; +} + +// ============================================================================ +// Local Helpers +// ============================================================================ + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch-switch flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. This is called before the branch switch to + * ensure remote refs are up-to-date for branch detection and checkout. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (controller.signal.aborted) { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs regardless of failure type + } finally { + clearTimeout(timerId); + } +} + +/** + * Parse a remote branch name like "origin/feature-branch" into its parts. + * Splits on the first slash so the remote is the segment before the first '/' + * and the branch is everything after it (preserving any subsequent slashes). + * For example, "origin/feature/my-branch" → { remote: "origin", branch: "feature/my-branch" }. + * Returns null if the input contains no slash. + */ +function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null { + const firstSlash = branchName.indexOf('/'); + if (firstSlash === -1) return null; + return { + remote: branchName.substring(0, firstSlash), + branch: branchName.substring(firstSlash + 1), + }; +} + +/** + * Check if a branch name refers to a remote branch + */ +async function isRemoteBranch(cwd: string, branchName: string): Promise { + try { + const stdout = await execGitCommand(['branch', '-r', '--format=%(refname:short)'], cwd); + const remoteBranches = stdout + .trim() + .split('\n') + .map((b) => b.trim().replace(/^['"]|['"]$/g, '')) + .filter((b) => b); + return remoteBranches.includes(branchName); + } catch (err) { + logger.error('isRemoteBranch: failed to list remote branches — returning false', { + branchName, + cwd, + error: getErrorMessage(err), + }); + return false; + } +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a full branch switch workflow on the given worktree. + * + * The workflow: + * 1. Fetch latest from all remotes (ensures remote refs are up-to-date) + * 2. Get current branch name + * 3. Detect remote vs local branch and determine target + * 4. Return early if already on target branch + * 5. Validate branch existence + * 6. Stash local changes if any + * 7. Checkout the target branch + * 8. Reapply stashed changes (detect conflicts) + * 9. Handle error recovery (restore stash if checkout fails) + * + * @param worktreePath - Path to the git worktree + * @param branchName - Branch to switch to (can be local or remote like "origin/feature") + * @param events - Optional event emitter for lifecycle events + * @returns SwitchBranchResult with detailed status information + */ +export async function performSwitchBranch( + worktreePath: string, + branchName: string, + events?: EventEmitter +): Promise { + // Emit start event + events?.emit('switch:start', { worktreePath, branchName }); + + // 1. Fetch latest from all remotes before switching + // This ensures remote branch refs are up-to-date so that isRemoteBranch() + // can detect newly created remote branches and local tracking branches + // are aware of upstream changes. + await fetchRemotes(worktreePath); + + // 2. Get current branch + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const previousBranch = currentBranchOutput.trim(); + + // 3. Determine the actual target branch name for checkout + let targetBranch = branchName; + let isRemote = false; + + // Check if this is a remote branch (e.g., "origin/feature-branch") + let parsedRemote: { remote: string; branch: string } | null = null; + if (await isRemoteBranch(worktreePath, branchName)) { + isRemote = true; + parsedRemote = parseRemoteBranch(branchName); + if (parsedRemote) { + targetBranch = parsedRemote.branch; + } else { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to parse remote branch name '${branchName}'`, + }); + return { + success: false, + error: `Failed to parse remote branch name '${branchName}'`, + }; + } + } + + // 4. Return early if already on the target branch + if (previousBranch === targetBranch) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + alreadyOnBranch: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Already on branch '${targetBranch}'`, + }, + }; + } + + // 5. Check if target branch exists as a local branch + if (!isRemote) { + if (!(await localBranchExists(worktreePath, branchName))) { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Branch '${branchName}' does not exist`, + }); + return { + success: false, + error: `Branch '${branchName}' does not exist`, + }; + } + } + + // 6. Stash local changes if any exist + const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true }); + let didStash = false; + + if (hadChanges) { + events?.emit('switch:stash', { + worktreePath, + previousBranch, + targetBranch, + action: 'push', + }); + const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`; + try { + didStash = await stashChanges(worktreePath, stashMessage, true); + } catch (stashError) { + const stashErrorMsg = getErrorMessage(stashError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to stash local changes: ${stashErrorMsg}`, + }); + return { + success: false, + error: `Failed to stash local changes before switching branches: ${stashErrorMsg}`, + }; + } + } + + try { + // 7. Switch to the target branch + events?.emit('switch:checkout', { + worktreePath, + targetBranch, + isRemote, + previousBranch, + }); + + if (isRemote) { + if (!parsedRemote) { + throw new Error(`Failed to parse remote branch name '${branchName}'`); + } + if (await localBranchExists(worktreePath, parsedRemote.branch)) { + // Local branch exists, just checkout + await execGitCommand(['checkout', parsedRemote.branch], worktreePath); + } else { + // Create local tracking branch from remote + await execGitCommand(['checkout', '-b', parsedRemote.branch, branchName], worktreePath); + } + } else { + await execGitCommand(['checkout', targetBranch], worktreePath); + } + + // 8. Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + let stashReapplied = false; + + if (didStash) { + events?.emit('switch:pop', { + worktreePath, + targetBranch, + action: 'pop', + }); + + const popResult = await popStash(worktreePath); + hasConflicts = popResult.hasConflicts; + if (popResult.hasConflicts) { + conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason - the stash is still there + conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } else { + stashReapplied = true; + } + } + + if (hasConflicts) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + hasConflicts: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }; + } else if (didStash && !stashReapplied) { + // Stash pop failed for a non-conflict reason — stash is still present + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + stashPopFailed: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: false, + stashedChanges: true, + }, + }; + } else { + const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : ''; + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + stashReapplied, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Switched to branch '${targetBranch}'${stashNote}`, + hasConflicts: false, + stashedChanges: stashReapplied, + }, + }; + } + } catch (checkoutError) { + // 9. Error recovery: if checkout failed and we stashed, try to restore the stash + if (didStash) { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + // Stash pop itself produced merge conflicts — the working tree is now in a + // conflicted state even though the checkout failed. Surface this clearly so + // the caller can prompt the user (or AI) to resolve conflicts rather than + // simply retrying the branch switch. + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + stashPopConflicts: true, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: true, + stashPopConflictMessage: + 'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' + + 'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.', + }; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason; the stash entry is still intact. + // Include this detail alongside the original checkout error. + const checkoutErrorMsg = getErrorMessage(checkoutError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` + + `${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + }; + } + // popResult.success === true: stash was cleanly restored, re-throw the checkout error + } + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + }); + throw checkoutError; + } +} diff --git a/apps/server/src/services/worktree-resolver.ts b/apps/server/src/services/worktree-resolver.ts new file mode 100644 index 00000000..48ae405d --- /dev/null +++ b/apps/server/src/services/worktree-resolver.ts @@ -0,0 +1,170 @@ +/** + * WorktreeResolver - Git worktree discovery and resolution + * + * Extracted from AutoModeService to provide a standalone service for: + * - Finding existing worktrees for a given branch + * - Getting the current branch of a repository + * - Listing all worktrees with their metadata + * + * Key behaviors: + * - Parses `git worktree list --porcelain` output + * - Always resolves paths to absolute (cross-platform compatibility) + * - Handles detached HEAD and bare worktrees gracefully + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; + +const execAsync = promisify(exec); + +/** + * Information about a git worktree + */ +export interface WorktreeInfo { + /** Absolute path to the worktree directory */ + path: string; + /** Branch name (without refs/heads/ prefix), or null if detached HEAD */ + branch: string | null; + /** Whether this is the main worktree (first in git worktree list) */ + isMain: boolean; +} + +/** + * WorktreeResolver handles git worktree discovery and path resolution. + * + * This service is responsible for: + * 1. Finding existing worktrees by branch name + * 2. Getting the current branch of a repository + * 3. Listing all worktrees with normalized paths + */ +export class WorktreeResolver { + /** + * Get the current branch name for a git repository + * + * @param projectPath - Path to the git repository + * @returns The current branch name, or null if not in a git repo or on detached HEAD + */ + async getCurrentBranch(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); + const branch = stdout.trim(); + return branch || null; + } catch { + return null; + } + } + + /** + * Find an existing worktree for a given branch name + * + * @param projectPath - Path to the git repository (main worktree) + * @param branchName - Branch name to find worktree for + * @returns Absolute path to the worktree, or null if not found + */ + async findWorktreeForBranch(projectPath: string, branchName: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '' && currentPath && currentBranch) { + // End of a worktree entry + if (currentBranch === branchName) { + // Resolve to absolute path - git may return relative paths + // On Windows, this is critical for cwd to work correctly + // On all platforms, absolute paths ensure consistent behavior + return this.resolvePath(projectPath, currentPath); + } + currentPath = null; + currentBranch = null; + } + } + + // Check the last entry (if file doesn't end with newline) + if (currentPath && currentBranch && currentBranch === branchName) { + return this.resolvePath(projectPath, currentPath); + } + + return null; + } catch { + return null; + } + } + + /** + * List all worktrees for a repository + * + * @param projectPath - Path to the git repository + * @returns Array of WorktreeInfo objects with normalized paths + */ + async listWorktrees(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const worktrees: WorktreeInfo[] = []; + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let isFirstWorktree = true; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line.startsWith('detached')) { + // Detached HEAD - branch is null + currentBranch = null; + } else if (line === '' && currentPath) { + // End of a worktree entry + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + currentPath = null; + currentBranch = null; + isFirstWorktree = false; + } + } + + // Handle last entry if file doesn't end with newline + if (currentPath) { + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + } + + return worktrees; + } catch { + return []; + } + } + + /** + * Resolve a path to absolute, handling both relative and absolute inputs + * + * @param projectPath - Base path for relative resolution + * @param worktreePath - Path from git worktree list output + * @returns Absolute path + */ + private resolvePath(projectPath: string, worktreePath: string): string { + return path.isAbsolute(worktreePath) + ? path.resolve(worktreePath) + : path.resolve(projectPath, worktreePath); + } +} diff --git a/apps/server/src/services/worktree-service.ts b/apps/server/src/services/worktree-service.ts new file mode 100644 index 00000000..0cb7a251 --- /dev/null +++ b/apps/server/src/services/worktree-service.ts @@ -0,0 +1,178 @@ +/** + * WorktreeService - File-system operations for git worktrees + * + * Extracted from the worktree create route to centralise file-copy logic, + * surface errors through an EventEmitter instead of swallowing them, and + * make the behaviour testable in isolation. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { execGitCommand } from '@automaker/git-utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; + +/** + * Get the list of remote names that have a branch matching the given branch name. + * + * Uses `git for-each-ref` to check cached remote refs, returning the names of + * any remotes that already have a branch with the same name as `currentBranch`. + * Returns an empty array when `hasAnyRemotes` is false or when no matching + * remote refs are found. + * + * This helps the UI distinguish between "branch exists on the tracking remote" + * vs "branch was pushed to a different remote". + * + * @param worktreePath - Path to the git worktree + * @param currentBranch - Branch name to search for on remotes + * @param hasAnyRemotes - Whether the repository has any remotes configured + * @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch + */ +export async function getRemotesWithBranch( + worktreePath: string, + currentBranch: string, + hasAnyRemotes: boolean +): Promise { + if (!hasAnyRemotes) { + return []; + } + + try { + const remoteRefsOutput = await execGitCommand( + ['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`], + worktreePath + ); + + if (!remoteRefsOutput.trim()) { + return []; + } + + return remoteRefsOutput + .trim() + .split('\n') + .map((ref) => { + // Extract remote name from "remote/branch" format + const slashIdx = ref.indexOf('/'); + return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref; + }) + .filter((name) => name.length > 0); + } catch { + // Ignore errors - return empty array + return []; + } +} + +/** + * Error thrown when one or more file copy operations fail during + * `copyConfiguredFiles`. The caller can inspect `failures` for details. + */ +export class CopyFilesError extends Error { + constructor(public readonly failures: Array<{ path: string; error: string }>) { + super(`Failed to copy ${failures.length} file(s): ${failures.map((f) => f.path).join(', ')}`); + this.name = 'CopyFilesError'; + } +} + +/** + * WorktreeService encapsulates file-system operations that run against + * git worktrees (e.g. copying project-configured files into a new worktree). + * + * All operations emit typed events so the frontend can stream progress to the + * user. Errors are collected and surfaced to the caller rather than silently + * swallowed. + */ +export class WorktreeService { + /** + * Copy files / directories listed in the project's `worktreeCopyFiles` + * setting from `projectPath` into `worktreePath`. + * + * Security: paths containing `..` segments or absolute paths are rejected. + * + * Events emitted via `emitter`: + * - `worktree:copy-files:copied` – a file or directory was successfully copied + * - `worktree:copy-files:skipped` – a source file was not found (ENOENT) + * - `worktree:copy-files:failed` – an unexpected error occurred copying a file + * + * @throws {CopyFilesError} if any copy operation fails for a reason other + * than ENOENT (missing source file). + */ + async copyConfiguredFiles( + projectPath: string, + worktreePath: string, + settingsService: SettingsService | undefined, + emitter: EventEmitter + ): Promise { + if (!settingsService) return; + + const projectSettings = await settingsService.getProjectSettings(projectPath); + const copyFiles = projectSettings.worktreeCopyFiles; + + if (!copyFiles || copyFiles.length === 0) return; + + const failures: Array<{ path: string; error: string }> = []; + + for (const relativePath of copyFiles) { + // Security: prevent path traversal + const normalized = path.normalize(relativePath); + if (normalized === '' || normalized === '.') { + const reason = 'Suspicious path rejected (empty or current-dir)'; + emitter.emit('worktree:copy-files:skipped', { + path: relativePath, + reason, + }); + continue; + } + if (normalized.startsWith('..') || path.isAbsolute(normalized)) { + const reason = 'Suspicious path rejected (traversal or absolute)'; + emitter.emit('worktree:copy-files:skipped', { + path: relativePath, + reason, + }); + continue; + } + + const sourcePath = path.join(projectPath, normalized); + const destPath = path.join(worktreePath, normalized); + + try { + // Check if source exists + const stat = await fs.stat(sourcePath); + + // Ensure destination directory exists + const destDir = path.dirname(destPath); + await fs.mkdir(destDir, { recursive: true }); + + if (stat.isDirectory()) { + // Recursively copy directory + await fs.cp(sourcePath, destPath, { recursive: true, force: true }); + } else { + // Copy single file + await fs.copyFile(sourcePath, destPath); + } + + emitter.emit('worktree:copy-files:copied', { + path: normalized, + type: stat.isDirectory() ? 'directory' : 'file', + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + emitter.emit('worktree:copy-files:skipped', { + path: normalized, + reason: 'File not found in project root', + }); + } else { + const errorMessage = err instanceof Error ? err.message : String(err); + emitter.emit('worktree:copy-files:failed', { + path: normalized, + error: errorMessage, + }); + failures.push({ path: normalized, error: errorMessage }); + } + } + } + + if (failures.length > 0) { + throw new CopyFilesError(failures); + } + } +} diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 00000000..5a9d4dd8 --- /dev/null +++ b/apps/server/src/services/zai-usage-service.ts @@ -0,0 +1,582 @@ +import { createLogger } from '@automaker/utils'; +import { createEventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; + +const logger = createLogger('ZaiUsage'); + +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + +/** + * z.ai quota limit entry from the API + */ +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; // epoch milliseconds +} + +/** + * z.ai usage details by model (for MCP tracking) + */ +export interface ZaiUsageDetail { + modelId: string; + used: number; + limit: number; +} + +/** + * z.ai plan types + */ +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +/** + * z.ai usage data structure + */ +export interface ZaiUsageData { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + usageDetails?: ZaiUsageDetail[]; + lastUpdated: string; +} + +/** + * z.ai API limit entry - supports multiple field naming conventions + */ +interface ZaiApiLimit { + // Type field (z.ai uses 'type', others might use 'limitType') + type?: string; + limitType?: string; + // Limit value (z.ai uses 'usage' for total limit, others might use 'limit') + usage?: number; + limit?: number; + // Used value (z.ai uses 'currentValue', others might use 'used') + currentValue?: number; + used?: number; + // Remaining + remaining?: number; + // Percentage (z.ai uses 'percentage', others might use 'usedPercent') + percentage?: number; + usedPercent?: number; + // Reset time + nextResetTime?: number; + // Additional z.ai fields + unit?: number; + number?: number; + usageDetails?: Array<{ modelCode: string; usage: number }>; +} + +/** + * z.ai API response structure + * Flexible to handle various possible response formats + */ +interface ZaiApiResponse { + code?: number; + success?: boolean; + data?: { + limits?: ZaiApiLimit[]; + // Alternative: limits might be an object instead of array + tokensLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + timeLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + // Quota-style fields + quota?: number; + quotaUsed?: number; + quotaRemaining?: number; + planName?: string; + plan?: string; + plan_type?: string; + packageName?: string; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + }; + // Root-level alternatives + limits?: ZaiApiLimit[]; + quota?: number; + quotaUsed?: number; + message?: string; +} + +/** Result from configure method */ +interface ConfigureResult { + success: boolean; + message: string; + isAvailable: boolean; +} + +/** Result from verifyApiKey method */ +interface VerifyResult { + success: boolean; + authenticated: boolean; + message?: string; + error?: string; +} + +/** + * z.ai Usage Service + * + * Fetches usage quota data from the z.ai API. + * Uses API token authentication stored via environment variable or settings. + */ +export class ZaiUsageService { + private apiToken: string | null = null; + private apiHost: string = 'https://api.z.ai'; + + /** + * Set the API token for authentication + */ + setApiToken(token: string): void { + this.apiToken = token; + logger.info('[setApiToken] API token configured'); + } + + /** + * Get the current API token + */ + getApiToken(): string | null { + // Priority: 1. Instance token, 2. Environment variable + return this.apiToken || process.env.Z_AI_API_KEY || null; + } + + /** + * Set the API host (for BigModel CN region support) + */ + setApiHost(host: string): void { + this.apiHost = host.startsWith('http') ? host : `https://${host}`; + logger.info(`[setApiHost] API host set to: ${this.apiHost}`); + } + + /** + * Get the API host + */ + getApiHost(): string { + // Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default + if (process.env.Z_AI_API_HOST) { + const envHost = process.env.Z_AI_API_HOST.trim(); + return envHost.startsWith('http') ? envHost : `https://${envHost}`; + } + return this.apiHost; + } + + /** + * Check if z.ai API is available (has token configured) + */ + isAvailable(): boolean { + const token = this.getApiToken(); + return Boolean(token && token.length > 0); + } + + /** + * Configure z.ai API token and host. + * Persists the token via settingsService and updates in-memory state. + */ + async configure( + options: { apiToken?: string; apiHost?: string }, + settingsService: SettingsService + ): Promise { + const emitter = createEventEmitter(); + + if (options.apiToken !== undefined) { + // Set in-memory token + this.setApiToken(options.apiToken || ''); + + // Persist to credentials + try { + await settingsService.updateCredentials({ + apiKeys: { zai: options.apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (options.apiHost) { + this.setApiHost(options.apiHost); + } + + const result: ConfigureResult = { + success: true, + message: 'z.ai configuration updated', + isAvailable: this.isAvailable(), + }; + + emitter.emit('notification:created', { + type: 'zai.configured', + success: result.success, + isAvailable: result.isAvailable, + }); + + return result; + } + + /** + * Verify an API key without storing it. + * Makes a test request to the z.ai quota URL with the given key. + */ + async verifyApiKey(apiKey: string | undefined): Promise { + const emitter = createEventEmitter(); + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + let result: VerifyResult; + + if (response.ok) { + result = { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } else if (response.status === 401 || response.status === 403) { + result = { + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }; + } else { + result = { + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }; + } + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: result.success, + authenticated: result.authenticated, + }); + + return result; + } catch (error) { + // Handle abort/timeout errors specifically + if (error instanceof Error && error.name === 'AbortError') { + const result: VerifyResult = { + success: false, + authenticated: false, + error: 'Request timed out. The z.ai API did not respond in time.', + }; + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: 'timeout', + }); + return result; + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: message, + }); + + return { + success: false, + authenticated: false, + error: `Network error: ${message}`, + }; + } + } + + /** + * Fetch usage data from z.ai API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + const emitter = createEventEmitter(); + + emitter.emit('notification:created', { type: 'zai.usage.start' }); + + const token = this.getApiToken(); + if (!token) { + logger.error('[fetchUsageData] No API token configured'); + const error = new Error( + 'z.ai API token not configured. Set Z_AI_API_KEY environment variable.' + ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); + throw error; + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + + const result = this.parseApiResponse(data); + + emitter.emit('notification:created', { + type: 'zai.usage.success', + data: result, + }); + + return result; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + // Handle abort/timeout errors + if (error instanceof Error && error.name === 'AbortError') { + const timeoutError = new Error(`z.ai API request timed out after ${FETCH_TIMEOUT_MS}ms`); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: timeoutError.message, + }); + throw timeoutError; + } + + if (error instanceof Error && error.message.includes('z.ai API')) { + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); + throw error; + } + + logger.error('[fetchUsageData] Failed to fetch:', error); + const fetchError = new Error( + `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` + ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: fetchError.message, + }); + throw fetchError; + } + } + + /** + * Parse the z.ai API response into our data structure + * Handles multiple possible response formats from z.ai API + */ + private parseApiResponse(response: ZaiApiResponse): ZaiUsageData { + const result: ZaiUsageData = { + quotaLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2)); + + // Try to find data - could be in response.data or at root level + let data = response.data; + + // Check for root-level limits array + if (!data && response.limits) { + logger.info('[parseApiResponse] Found limits at root level'); + data = { limits: response.limits }; + } + + // Check for root-level quota fields + if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) { + logger.info('[parseApiResponse] Found quota fields at root level'); + data = { quota: response.quota, quotaUsed: response.quotaUsed }; + } + + if (!data) { + logger.warn('[parseApiResponse] No data found in response'); + return result; + } + + logger.info('[parseApiResponse] Data keys:', Object.keys(data)); + + // Parse plan type from various possible field names + const planName = data.planName || data.plan || data.plan_type || data.packageName; + + if (planName) { + const normalizedPlan = String(planName).toLowerCase(); + if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) { + result.quotaLimits!.planType = normalizedPlan as ZaiPlanType; + } + logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`); + } + + // Parse quota limits from array format + if (data.limits && Array.isArray(data.limits)) { + logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries'); + for (const limit of data.limits) { + logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit)); + + // Handle different field naming conventions from z.ai API: + // - 'usage' is the total limit, 'currentValue' is the used amount + // - OR 'limit' is the total limit, 'used' is the used amount + const limitVal = limit.usage ?? limit.limit ?? 0; + const usedVal = limit.currentValue ?? limit.used ?? 0; + + // Get percentage from 'percentage' or 'usedPercent' field, or calculate it + const apiPercent = limit.percentage ?? limit.usedPercent; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + const usedPercent = + apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent; + + // Get limit type from 'type' or 'limitType' field + const rawLimitType = limit.type ?? limit.limitType ?? ''; + + const quotaLimit: ZaiQuotaLimit = { + limitType: rawLimitType || 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: limit.remaining ?? limitVal - usedVal, + usedPercent, + nextResetTime: limit.nextResetTime ?? 0, + }; + + // Match various possible limitType values + const limitType = String(rawLimitType).toUpperCase(); + if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') { + result.quotaLimits!.tokens = quotaLimit; + logger.info( + `[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') { + result.quotaLimits!.mcp = quotaLimit; + logger.info( + `[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else { + // If limitType is unknown, use as tokens by default (first one) + if (!result.quotaLimits!.tokens) { + quotaLimit.limitType = 'TOKENS_LIMIT'; + result.quotaLimits!.tokens = quotaLimit; + logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`); + } + } + } + } + + // Parse alternative object-style limits + if (data.tokensLimit) { + const t = data.tokensLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed tokensLimit object'); + } + + if (data.timeLimit) { + const t = data.timeLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.mcp = { + limitType: 'TIME_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed timeLimit object'); + } + + // Parse simple quota/quotaUsed format as tokens + if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) { + const limitVal = Number(data.quota) || 0; + const usedVal = Number(data.quotaUsed) || 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: + data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal, + usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0, + nextResetTime: 0, + }; + logger.info('[parseApiResponse] Parsed simple quota format'); + } + + // Parse usage details (MCP tracking) + if (data.usageDetails && Array.isArray(data.usageDetails)) { + result.usageDetails = data.usageDetails.map((detail) => ({ + modelId: detail.modelId, + used: detail.used, + limit: detail.limit, + })); + logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`); + } + + logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/apps/server/src/tests/cli-integration.test.ts b/apps/server/src/tests/cli-integration.test.ts index 7e84eb54..695e8ea0 100644 --- a/apps/server/src/tests/cli-integration.test.ts +++ b/apps/server/src/tests/cli-integration.test.ts @@ -5,7 +5,7 @@ * across all providers (Claude, Codex, Cursor) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { detectCli, detectAllCLis, @@ -64,7 +64,7 @@ describe('CLI Detection Framework', () => { }); it('should handle unsupported platform', () => { - const instructions = getInstallInstructions('claude', 'unknown-platform' as any); + const instructions = getInstallInstructions('claude', 'unknown-platform' as NodeJS.Platform); expect(instructions).toContain('No installation instructions available'); }); }); @@ -270,7 +270,7 @@ describe('Error Recovery Tests', () => { expect(results).toHaveProperty('cursor'); // Should provide error information for failures - Object.entries(results).forEach(([provider, result]) => { + Object.entries(results).forEach(([_provider, result]) => { if (!result.detected && result.issues.length > 0) { expect(result.issues.length).toBeGreaterThan(0); expect(result.issues[0]).toBeTruthy(); @@ -339,15 +339,17 @@ describe('Performance Tests', () => { // Edge Cases describe('Edge Cases', () => { it('should handle empty CLI names', async () => { - await expect(detectCli('' as any)).rejects.toThrow(); + await expect(detectCli('' as unknown as Parameters[0])).rejects.toThrow(); }); it('should handle null CLI names', async () => { - await expect(detectCli(null as any)).rejects.toThrow(); + await expect(detectCli(null as unknown as Parameters[0])).rejects.toThrow(); }); it('should handle undefined CLI names', async () => { - await expect(detectCli(undefined as any)).rejects.toThrow(); + await expect( + detectCli(undefined as unknown as Parameters[0]) + ).rejects.toThrow(); }); it('should handle malformed error objects', () => { diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index 6863b314..30ce1722 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -23,6 +23,7 @@ export type { PhaseModelConfig, PhaseModelKey, PhaseModelEntry, + FeatureTemplate, // Claude-compatible provider types ApiKeySource, ClaudeCompatibleProviderType, @@ -41,6 +42,7 @@ export { DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, diff --git a/apps/server/test/git-log-parser.test.js b/apps/server/test/git-log-parser.test.js new file mode 100644 index 00000000..c81c4958 --- /dev/null +++ b/apps/server/test/git-log-parser.test.js @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { parseGitLogOutput } from '../src/lib/git-log-parser.js'; + +// Mock data: fields within each commit are newline-separated, +// commits are NUL-separated (matching the parser contract). +const mockGitOutput = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Mock data where commit bodies contain ---END--- markers +const mockOutputWithEndMarker = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Single-commit mock: fields newline-separated, no trailing NUL needed +const singleCommitOutput = + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body'; + +describe('parseGitLogOutput', () => { + describe('normal parsing (three commits)', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is the commit body'); + }); + + it('parses the second commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv'); + expect(commits[1].shortHash).toBe('e5f6g7'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl'); + expect(commits[2].shortHash).toBe('q1w2e3'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('parsing with ---END--- in commit messages', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits.length).toBe(3); + }); + + it('preserves ---END--- text in the body of the first commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toMatch(/---END---/); + }); + + it('preserves ---END--- text in the body of the second commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit without ---END--- interference', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('empty output', () => { + it('returns an empty array for an empty string', () => { + const commits = parseGitLogOutput(''); + expect(commits).toEqual([]); + expect(commits.length).toBe(0); + }); + }); + + describe('single-commit output', () => { + it('returns exactly one commit', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits.length).toBe(1); + }); + + it('parses the single commit fields correctly', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Single commit'); + expect(commits[0].body).toBe('Single commit body'); + }); + }); + + describe('multi-line commit body', () => { + // Test vector from test-proper-nul-format.js: commit with a 3-line body + const multiLineBodyOutput = + [ + 'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body', + 'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message', + 'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line', + ].join('\0') + '\0'; + + it('returns 3 commits', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].shortHash).toBe('abc1'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is a normal commit body'); + }); + + it('parses the second commit with ---END--- in body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[1].hash).toBe('def456'); + expect(commits[1].shortHash).toBe('def4'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toContain('---END---'); + }); + + it('parses the third commit with a multi-line body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[2].hash).toBe('ghi789'); + expect(commits[2].shortHash).toBe('ghi7'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line'); + }); + }); + + describe('commit with empty body (trailing blank lines after subject)', () => { + // Test vector from test-proper-nul-format.js: empty body commit + const emptyBodyOutput = + 'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0'; + + it('returns 1 commit', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits.length).toBe(1); + }); + + it('parses the commit subject correctly', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].hash).toBe('empty123'); + expect(commits[0].shortHash).toBe('empty1'); + expect(commits[0].author).toBe('Alice Brown'); + expect(commits[0].subject).toBe('Empty body commit'); + }); + + it('produces an empty body string when only blank lines follow the subject', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].body).toBe(''); + }); + }); + + describe('leading empty lines in a commit block', () => { + // Blocks that start with blank lines before the hash field + const outputWithLeadingBlanks = + '\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here'; + + it('returns 1 commit despite leading blank lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits.length).toBe(1); + }); + + it('parses the commit fields correctly when block has leading empty lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].subject).toBe('Subject here'); + expect(commits[0].body).toBe('Body here'); + }); + }); +}); diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts deleted file mode 100644 index e0ab4c4d..00000000 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; -import { ProviderFactory } from '@/providers/provider-factory.js'; -import { FeatureLoader } from '@/services/feature-loader.js'; -import { - createTestGitRepo, - createTestFeature, - listBranches, - listWorktrees, - branchExists, - worktreeExists, - type TestRepo, -} from '../helpers/git-test-repo.js'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(exec); - -vi.mock('@/providers/provider-factory.js'); - -describe('auto-mode-service.ts (integration)', () => { - let service: AutoModeService; - let testRepo: TestRepo; - let featureLoader: FeatureLoader; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(async () => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - featureLoader = new FeatureLoader(); - testRepo = await createTestGitRepo(); - }); - - afterEach(async () => { - // Stop any running auto loops - await service.stopAutoLoop(); - - // Cleanup test repo - if (testRepo) { - await testRepo.cleanup(); - } - }); - - describe('worktree operations', () => { - it('should use existing git worktree for feature', async () => { - const branchName = 'feature/test-feature-1'; - - // Create a test feature with branchName set - await createTestFeature(testRepo.path, 'test-feature-1', { - id: 'test-feature-1', - category: 'test', - description: 'Test feature', - status: 'pending', - branchName: branchName, - }); - - // Create worktree before executing (worktrees are now created when features are added/edited) - const worktreesDir = path.join(testRepo.path, '.worktrees'); - const worktreePath = path.join(worktreesDir, 'test-feature-1'); - await fs.mkdir(worktreesDir, { recursive: true }); - await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { - cwd: testRepo.path, - }); - - // Mock provider to complete quickly - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Feature implemented' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute feature with worktrees enabled - await service.executeFeature( - testRepo.path, - 'test-feature-1', - true, // useWorktrees - false // isAutoMode - ); - - // Verify branch exists (was created when worktree was created) - const branches = await listBranches(testRepo.path); - expect(branches).toContain(branchName); - - // Verify worktree exists and is being used - // The service should have found and used the worktree (check via logs) - // We can verify the worktree exists by checking git worktree list - const worktrees = await listWorktrees(testRepo.path); - expect(worktrees.length).toBeGreaterThan(0); - // Verify that at least one worktree path contains our feature ID - const worktreePathsMatch = worktrees.some( - (wt) => wt.includes('test-feature-1') || wt.includes('.worktrees') - ); - expect(worktreePathsMatch).toBe(true); - - // Note: Worktrees are not automatically cleaned up by the service - // This is expected behavior - manual cleanup is required - }, 30000); - - it('should handle error gracefully', async () => { - await createTestFeature(testRepo.path, 'test-feature-error', { - id: 'test-feature-error', - category: 'test', - description: 'Test feature that errors', - status: 'pending', - }); - - // Mock provider that throws error - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - throw new Error('Provider error'); - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute feature (should handle error) - await service.executeFeature(testRepo.path, 'test-feature-error', true, false); - - // Verify feature status was updated to backlog (error status) - const feature = await featureLoader.get(testRepo.path, 'test-feature-error'); - expect(feature?.status).toBe('backlog'); - }, 30000); - - it('should work without worktrees', async () => { - await createTestFeature(testRepo.path, 'test-no-worktree', { - id: 'test-no-worktree', - category: 'test', - description: 'Test without worktree', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Execute without worktrees - await service.executeFeature( - testRepo.path, - 'test-no-worktree', - false, // useWorktrees = false - false - ); - - // Feature should be updated successfully - const feature = await featureLoader.get(testRepo.path, 'test-no-worktree'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - }); - - describe('feature execution', () => { - it('should execute feature and update status', async () => { - await createTestFeature(testRepo.path, 'feature-exec-1', { - id: 'feature-exec-1', - category: 'ui', - description: 'Execute this feature', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Implemented the feature' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature( - testRepo.path, - 'feature-exec-1', - false, // Don't use worktrees so agent output is saved to main project - false - ); - - // Check feature status was updated - const feature = await featureLoader.get(testRepo.path, 'feature-exec-1'); - expect(feature?.status).toBe('waiting_approval'); - - // Check agent output was saved - const agentOutput = await featureLoader.getAgentOutput(testRepo.path, 'feature-exec-1'); - expect(agentOutput).toBeTruthy(); - expect(agentOutput).toContain('Implemented the feature'); - }, 30000); - - it('should handle feature not found', async () => { - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Try to execute non-existent feature - await service.executeFeature(testRepo.path, 'nonexistent-feature', true, false); - - // Should emit error event - expect(mockEvents.emit).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - featureId: 'nonexistent-feature', - error: expect.stringContaining('not found'), - }) - ); - }, 30000); - - it('should prevent duplicate feature execution', async () => { - await createTestFeature(testRepo.path, 'feature-dup', { - id: 'feature-dup', - category: 'test', - description: 'Duplicate test', - status: 'pending', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - // Simulate slow execution - await new Promise((resolve) => setTimeout(resolve, 500)); - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start first execution - const promise1 = service.executeFeature(testRepo.path, 'feature-dup', false, false); - - // Try to start second execution (should throw) - await expect( - service.executeFeature(testRepo.path, 'feature-dup', false, false) - ).rejects.toThrow('already running'); - - await promise1; - }, 30000); - - it('should use feature-specific model', async () => { - await createTestFeature(testRepo.path, 'feature-model', { - id: 'feature-model', - category: 'test', - description: 'Model test', - status: 'pending', - model: 'claude-sonnet-4-20250514', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'feature-model', false, false); - - // Should have used claude-sonnet-4-20250514 - expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514'); - }, 30000); - }); - - describe('auto loop', () => { - it('should start and stop auto loop', async () => { - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Give it time to start - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Stop the loop - const runningCount = await service.stopAutoLoop(); - - expect(runningCount).toBe(0); - await startPromise.catch(() => {}); // Cleanup - }, 10000); - - it('should process pending features in auto loop', async () => { - // Create multiple pending features - await createTestFeature(testRepo.path, 'auto-1', { - id: 'auto-1', - category: 'test', - description: 'Auto feature 1', - status: 'pending', - skipTests: true, - }); - - await createTestFeature(testRepo.path, 'auto-2', { - id: 'auto-2', - category: 'test', - description: 'Auto feature 2', - status: 'pending', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start auto loop - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Wait for features to be processed - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // Stop the loop - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Check that features were updated - const feature1 = await featureLoader.get(testRepo.path, 'auto-1'); - const feature2 = await featureLoader.get(testRepo.path, 'auto-2'); - - // At least one should have been processed - const processedCount = [feature1, feature2].filter( - (f) => f?.status === 'waiting_approval' || f?.status === 'in_progress' - ).length; - - expect(processedCount).toBeGreaterThan(0); - }, 15000); - - it('should respect max concurrency', async () => { - // Create 5 features - for (let i = 1; i <= 5; i++) { - await createTestFeature(testRepo.path, `concurrent-${i}`, { - id: `concurrent-${i}`, - category: 'test', - description: `Concurrent feature ${i}`, - status: 'pending', - }); - } - - let concurrentCount = 0; - let maxConcurrent = 0; - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - concurrentCount++; - maxConcurrent = Math.max(maxConcurrent, concurrentCount); - - // Simulate work - await new Promise((resolve) => setTimeout(resolve, 500)); - - concurrentCount--; - - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Start with max concurrency of 2 - const startPromise = service.startAutoLoop(testRepo.path, 2); - - // Wait for some features to be processed - await new Promise((resolve) => setTimeout(resolve, 3000)); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Max concurrent should not exceed 2 - expect(maxConcurrent).toBeLessThanOrEqual(2); - }, 15000); - - it('should emit auto mode events', async () => { - const startPromise = service.startAutoLoop(testRepo.path, 1); - - // Wait for start event - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Check start event was emitted - const startEvent = mockEvents.emit.mock.calls.find((call) => - call[1]?.message?.includes('Auto mode started') - ); - expect(startEvent).toBeTruthy(); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Check stop event was emitted (emitted immediately by stopAutoLoop) - const stopEvent = mockEvents.emit.mock.calls.find( - (call) => - call[1]?.type === 'auto_mode_stopped' || call[1]?.message?.includes('Auto mode stopped') - ); - expect(stopEvent).toBeTruthy(); - }, 10000); - }); - - describe('error handling', () => { - it('should handle provider errors gracefully', async () => { - await createTestFeature(testRepo.path, 'error-feature', { - id: 'error-feature', - category: 'test', - description: 'Error test', - status: 'pending', - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - throw new Error('Provider execution failed'); - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - // Should not throw - await service.executeFeature(testRepo.path, 'error-feature', true, false); - - // Feature should be marked as backlog (error status) - const feature = await featureLoader.get(testRepo.path, 'error-feature'); - expect(feature?.status).toBe('backlog'); - }, 30000); - - it('should continue auto loop after feature error', async () => { - await createTestFeature(testRepo.path, 'fail-1', { - id: 'fail-1', - category: 'test', - description: 'Will fail', - status: 'pending', - }); - - await createTestFeature(testRepo.path, 'success-1', { - id: 'success-1', - category: 'test', - description: 'Will succeed', - status: 'pending', - }); - - let callCount = 0; - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - callCount++; - if (callCount === 1) { - throw new Error('First feature fails'); - } - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - const startPromise = service.startAutoLoop(testRepo.path, 1); - - // Wait for both features to be attempted - await new Promise((resolve) => setTimeout(resolve, 5000)); - - await service.stopAutoLoop(); - await startPromise.catch(() => {}); - - // Both features should have been attempted - expect(callCount).toBeGreaterThanOrEqual(1); - }, 15000); - }); - - describe('planning mode', () => { - it('should execute feature with skip planning mode', async () => { - await createTestFeature(testRepo.path, 'skip-plan-feature', { - id: 'skip-plan-feature', - category: 'test', - description: 'Feature with skip planning', - status: 'pending', - planningMode: 'skip', - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Feature implemented' }], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'skip-plan-feature', false, false); - - const feature = await featureLoader.get(testRepo.path, 'skip-plan-feature'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - - it('should execute feature with lite planning mode without approval', async () => { - await createTestFeature(testRepo.path, 'lite-plan-feature', { - id: 'lite-plan-feature', - category: 'test', - description: 'Feature with lite planning', - status: 'pending', - planningMode: 'lite', - requirePlanApproval: false, - skipTests: true, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'text', - text: '[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented', - }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'lite-plan-feature', false, false); - - const feature = await featureLoader.get(testRepo.path, 'lite-plan-feature'); - expect(feature?.status).toBe('waiting_approval'); - }, 30000); - - it('should emit planning_started event for spec mode', async () => { - await createTestFeature(testRepo.path, 'spec-plan-feature', { - id: 'spec-plan-feature', - category: 'test', - description: 'Feature with spec planning', - status: 'pending', - planningMode: 'spec', - requirePlanApproval: false, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'Spec generated\n\n[SPEC_GENERATED] Review the spec.' }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'spec-plan-feature', false, false); - - // Check planning_started event was emitted - const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'spec'); - expect(planningEvent).toBeTruthy(); - }, 30000); - - it('should handle feature with full planning mode', async () => { - await createTestFeature(testRepo.path, 'full-plan-feature', { - id: 'full-plan-feature', - category: 'test', - description: 'Feature with full planning', - status: 'pending', - planningMode: 'full', - requirePlanApproval: false, - }); - - const mockProvider = { - getName: () => 'claude', - executeQuery: async function* () { - yield { - type: 'assistant', - message: { - role: 'assistant', - content: [ - { type: 'text', text: 'Full spec with phases\n\n[SPEC_GENERATED] Review.' }, - ], - }, - }; - yield { - type: 'result', - subtype: 'success', - }; - }, - }; - - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - - await service.executeFeature(testRepo.path, 'full-plan-feature', false, false); - - // Check planning_started event was emitted with full mode - const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'full'); - expect(planningEvent).toBeTruthy(); - }, 30000); - - it('should track pending approval correctly', async () => { - // Initially no pending approvals - expect(service.hasPendingApproval('non-existent')).toBe(false); - }); - - it('should cancel pending approval gracefully', () => { - // Should not throw when cancelling non-existent approval - expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); - }); - - it('should resolve approval with error for non-existent feature', async () => { - const result = await service.resolvePlanApproval( - 'non-existent', - true, - undefined, - undefined, - undefined - ); - expect(result.success).toBe(false); - expect(result.error).toContain('No pending approval'); - }); - }); -}); diff --git a/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/apps/server/tests/unit/lib/enhancement-prompts.test.ts index 13d61555..77d118d3 100644 --- a/apps/server/tests/unit/lib/enhancement-prompts.test.ts +++ b/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -168,7 +168,7 @@ describe('enhancement-prompts.ts', () => { const prompt = buildUserPrompt('improve', testText); expect(prompt).toContain('Example 1:'); expect(prompt).toContain(testText); - expect(prompt).toContain('Now, please enhance the following task description:'); + expect(prompt).toContain('Please enhance the following task description:'); }); it('should build prompt without examples when includeExamples is false', () => { diff --git a/apps/server/tests/unit/lib/git-log-parser.test.ts b/apps/server/tests/unit/lib/git-log-parser.test.ts new file mode 100644 index 00000000..53c5342c --- /dev/null +++ b/apps/server/tests/unit/lib/git-log-parser.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { parseGitLogOutput } from '../../../src/lib/git-log-parser.js'; + +// Mock data: fields within each commit are newline-separated, +// commits are NUL-separated (matching the parser contract). +const mockGitOutput = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Mock data where commit bodies contain ---END--- markers +const mockOutputWithEndMarker = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Single-commit mock: fields newline-separated, no trailing NUL needed +const singleCommitOutput = + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body'; + +describe('parseGitLogOutput', () => { + describe('normal parsing (three commits)', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is the commit body'); + }); + + it('parses the second commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv'); + expect(commits[1].shortHash).toBe('e5f6g7'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl'); + expect(commits[2].shortHash).toBe('q1w2e3'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('parsing with ---END--- in commit messages', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits.length).toBe(3); + }); + + it('preserves ---END--- text in the body of the first commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toMatch(/---END---/); + }); + + it('preserves ---END--- text in the body of the second commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit without ---END--- interference', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('empty output', () => { + it('returns an empty array for an empty string', () => { + const commits = parseGitLogOutput(''); + expect(commits).toEqual([]); + expect(commits.length).toBe(0); + }); + }); + + describe('single-commit output', () => { + it('returns exactly one commit', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits.length).toBe(1); + }); + + it('parses the single commit fields correctly', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Single commit'); + expect(commits[0].body).toBe('Single commit body'); + }); + }); + + describe('multi-line commit body', () => { + // Test vector from test-proper-nul-format.js: commit with a 3-line body + const multiLineBodyOutput = + [ + 'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body', + 'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message', + 'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line', + ].join('\0') + '\0'; + + it('returns 3 commits', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].shortHash).toBe('abc1'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is a normal commit body'); + }); + + it('parses the second commit with ---END--- in body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[1].hash).toBe('def456'); + expect(commits[1].shortHash).toBe('def4'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toContain('---END---'); + }); + + it('parses the third commit with a multi-line body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[2].hash).toBe('ghi789'); + expect(commits[2].shortHash).toBe('ghi7'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line'); + }); + }); + + describe('commit with empty body (trailing blank lines after subject)', () => { + // Test vector from test-proper-nul-format.js: empty body commit + const emptyBodyOutput = + 'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0'; + + it('returns 1 commit', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits.length).toBe(1); + }); + + it('parses the commit subject correctly', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].hash).toBe('empty123'); + expect(commits[0].shortHash).toBe('empty1'); + expect(commits[0].author).toBe('Alice Brown'); + expect(commits[0].subject).toBe('Empty body commit'); + }); + + it('produces an empty body string when only blank lines follow the subject', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].body).toBe(''); + }); + }); + + describe('leading empty lines in a commit block', () => { + // Blocks that start with blank lines before the hash field + const outputWithLeadingBlanks = + '\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here'; + + it('returns 1 commit despite leading blank lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits.length).toBe(1); + }); + + it('parses the commit fields correctly when block has leading empty lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].subject).toBe('Subject here'); + expect(commits[0].body).toBe('Body here'); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c1bff78d..65e3115d 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -35,7 +35,7 @@ describe('model-resolver.ts', () => { it("should resolve 'opus' alias to full model string", () => { const result = resolveModelString('opus'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); expect(consoleSpy.log).toHaveBeenCalledWith( expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); @@ -117,7 +117,7 @@ describe('model-resolver.ts', () => { describe('getEffectiveModel', () => { it('should prioritize explicit model over session and default', () => { const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); }); it('should use session model when explicit is not provided', () => { diff --git a/apps/server/tests/unit/lib/nul-delimiter.test.ts b/apps/server/tests/unit/lib/nul-delimiter.test.ts new file mode 100644 index 00000000..5cf20bdc --- /dev/null +++ b/apps/server/tests/unit/lib/nul-delimiter.test.ts @@ -0,0 +1,83 @@ +// Automated tests for NUL character behavior in git commit parsing + +import { describe, it, expect } from 'vitest'; + +describe('NUL character behavior', () => { + // Create a string with NUL characters + const str1 = + 'abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00'; + + describe('split on NUL character', () => { + const parts = str1.split('\0'); + + it('should produce the expected number of parts', () => { + // 7 fields + 1 trailing empty string from the trailing \x00 + expect(parts.length).toBe(8); + }); + + it('should contain the expected part values', () => { + expect(parts[0]).toBe('abc123'); + expect(parts[1]).toBe('abc1'); + expect(parts[2]).toBe('John Doe'); + expect(parts[3]).toBe('john@example.com'); + expect(parts[4]).toBe('2023-01-01T12:00:00Z'); + expect(parts[5]).toBe('Initial commit'); + expect(parts[6]).toBe('This is a normal commit body'); + expect(parts[7]).toBe(''); + }); + + it('should have correct lengths for each part', () => { + expect(parts[0].length).toBe(6); // 'abc123' + expect(parts[1].length).toBe(4); // 'abc1' + expect(parts[2].length).toBe(8); // 'John Doe' + expect(parts[3].length).toBe(16); // 'john@example.com' + expect(parts[4].length).toBe(20); // '2023-01-01T12:00:00Z' + expect(parts[5].length).toBe(14); // 'Initial commit' + expect(parts[6].length).toBe(28); // 'This is a normal commit body' + expect(parts[7].length).toBe(0); // trailing empty + }); + }); + + describe('git format split and filter', () => { + const gitFormat = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00Body text here\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Second commit\x00Body with ---END--- text\x00`; + + const gitParts = gitFormat.split('\0').filter((block) => block.trim()); + + it('should produce the expected number of non-empty parts after filtering', () => { + // 14 non-empty field strings (7 fields per commit × 2 commits); trailing empty is filtered out + expect(gitParts.length).toBe(14); + }); + + it('should contain correct field values for the first commit', () => { + const fields = gitParts.slice(0, 7); + expect(fields.length).toBe(7); + expect(fields[0]).toBe('abc123'); // hash + expect(fields[1]).toBe('abc1'); // shortHash + expect(fields[2]).toBe('John Doe'); // author + expect(fields[3]).toBe('john@example.com'); // authorEmail + expect(fields[4]).toBe('2023-01-01T12:00:00Z'); // date + expect(fields[5]).toBe('Initial commit'); // subject + expect(fields[6]).toBe('Body text here'); // body + }); + + it('should contain correct field values for the second commit', () => { + const fields = gitParts.slice(7, 14); + expect(fields.length).toBe(7); + expect(fields[0]).toBe('def456'); // hash + expect(fields[1]).toBe('def4'); // shortHash + expect(fields[2]).toBe('Jane Smith'); // author + expect(fields[3]).toBe('jane@example.com'); // authorEmail + expect(fields[4]).toBe('2023-01-02T12:00:00Z'); // date + expect(fields[5]).toBe('Second commit'); // subject + expect(fields[6]).toBe('Body with ---END--- text'); // body (---END--- handled correctly) + }); + + it('each part should have the expected number of newline-delimited fields', () => { + // Each gitPart is a single field value (no internal newlines), so split('\n') yields 1 field + gitParts.forEach((block) => { + const fields = block.split('\n'); + expect(fields.length).toBe(1); + }); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 029cd8fa..f552efd9 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -50,15 +50,15 @@ describe('sdk-options.ts', () => { describe('getModelForUseCase', () => { it('should return explicit model when provided', async () => { const { getModelForUseCase } = await import('@/lib/sdk-options.js'); - const result = getModelForUseCase('spec', 'claude-sonnet-4-20250514'); - expect(result).toBe('claude-sonnet-4-20250514'); + const result = getModelForUseCase('spec', 'claude-sonnet-4-6'); + expect(result).toBe('claude-sonnet-4-6'); }); it('should use environment variable for spec model', async () => { - process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-20250514'; + process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-6'; const { getModelForUseCase } = await import('@/lib/sdk-options.js'); const result = getModelForUseCase('spec'); - expect(result).toBe('claude-sonnet-4-20250514'); + expect(result).toBe('claude-sonnet-4-6'); }); it('should use default model for spec when no override', async () => { @@ -71,10 +71,10 @@ describe('sdk-options.ts', () => { it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => { delete process.env.AUTOMAKER_MODEL_SPEC; - process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-20250514'; + process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-6'; const { getModelForUseCase } = await import('@/lib/sdk-options.js'); const result = getModelForUseCase('spec'); - expect(result).toBe('claude-sonnet-4-20250514'); + expect(result).toBe('claude-sonnet-4-6'); }); }); @@ -203,10 +203,10 @@ describe('sdk-options.ts', () => { const options = createChatOptions({ cwd: '/test/path', - sessionModel: 'claude-sonnet-4-20250514', + sessionModel: 'claude-sonnet-4-6', }); - expect(options.model).toBe('claude-sonnet-4-20250514'); + expect(options.model).toBe('claude-sonnet-4-6'); }); }); @@ -491,5 +491,29 @@ describe('sdk-options.ts', () => { expect(options.maxThinkingTokens).toBeUndefined(); }); }); + + describe('adaptive thinking for Opus 4.6', () => { + it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'adaptive', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); }); }); diff --git a/apps/server/tests/unit/lib/thinking-level-normalization.test.ts b/apps/server/tests/unit/lib/thinking-level-normalization.test.ts new file mode 100644 index 00000000..35f1b6e0 --- /dev/null +++ b/apps/server/tests/unit/lib/thinking-level-normalization.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeThinkingLevelForModel } from '@automaker/types'; + +describe('normalizeThinkingLevelForModel', () => { + it('preserves explicitly selected none for Opus models', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'none')).toBe('none'); + }); + + it('falls back to none when Opus receives an unsupported manual thinking level', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'medium')).toBe('none'); + }); + + it('keeps adaptive for Opus when adaptive is selected', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'adaptive')).toBe('adaptive'); + }); + + it('preserves supported manual levels for non-Opus models', () => { + expect(normalizeThinkingLevelForModel('claude-sonnet', 'high')).toBe('high'); + }); +}); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index c3f83f8f..8a3850a6 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -5,6 +5,17 @@ import { collectAsyncGenerator } from '../../utils/helpers.js'; vi.mock('@anthropic-ai/claude-agent-sdk'); +vi.mock('@automaker/platform', () => ({ + getClaudeAuthIndicators: vi.fn().mockResolvedValue({ + hasCredentialsFile: false, + hasSettingsFile: false, + hasStatsCacheWithActivity: false, + hasProjectsSessions: false, + credentials: null, + checks: {}, + }), +})); + describe('claude-provider.ts', () => { let provider: ClaudeProvider; @@ -39,7 +50,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Hello', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -59,7 +70,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test prompt', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test/dir', systemPrompt: 'You are helpful', maxTurns: 10, @@ -71,7 +82,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test prompt', options: expect.objectContaining({ - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', systemPrompt: 'You are helpful', maxTurns: 10, cwd: '/test/dir', @@ -91,7 +102,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -116,7 +127,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', abortController, }); @@ -145,7 +156,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Current message', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', @@ -176,7 +187,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: arrayPrompt as any, - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -187,7 +198,7 @@ describe('claude-provider.ts', () => { expect(typeof callArgs.prompt).not.toBe('string'); }); - it('should use maxTurns default of 20', async () => { + it('should use maxTurns default of 1000', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; @@ -196,7 +207,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -205,7 +216,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ - maxTurns: 20, + maxTurns: 1000, }), }); }); @@ -222,7 +233,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -286,7 +297,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -313,7 +324,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -341,7 +352,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -360,27 +371,27 @@ describe('claude-provider.ts', () => { }); describe('getAvailableModels', () => { - it('should return 4 Claude models', () => { + it('should return 5 Claude models', () => { const models = provider.getAvailableModels(); - expect(models).toHaveLength(4); + expect(models).toHaveLength(5); }); - it('should include Claude Opus 4.5', () => { + it('should include Claude Opus 4.6', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5'); + expect(opus?.name).toBe('Claude Opus 4.6'); expect(opus?.provider).toBe('anthropic'); }); - it('should include Claude Sonnet 4', () => { + it('should include Claude Sonnet 4.6', () => { const models = provider.getAvailableModels(); - const sonnet = models.find((m) => m.id === 'claude-sonnet-4-20250514'); + const sonnet = models.find((m) => m.id === 'claude-sonnet-4-6'); expect(sonnet).toBeDefined(); - expect(sonnet?.name).toBe('Claude Sonnet 4'); + expect(sonnet?.name).toBe('Claude Sonnet 4.6'); }); it('should include Claude 3.5 Sonnet', () => { @@ -400,7 +411,7 @@ describe('claude-provider.ts', () => { it('should mark Opus as default', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus?.default).toBe(true); }); diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index a0bd25f6..1e150ee1 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -170,6 +170,30 @@ describe('codex-provider.ts', () => { expect(call.args).toContain('--json'); }); + it('uses exec resume when sdkSessionId is provided', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Continue', + model: 'gpt-5.2', + cwd: '/tmp', + sdkSessionId: 'codex-session-123', + outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } }, + codexSettings: { additionalDirs: ['/extra/dir'] }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args[0]).toBe('exec'); + expect(call.args[1]).toBe('resume'); + expect(call.args).toContain('codex-session-123'); + expect(call.args).toContain('--json'); + // Resume queries must not include --output-schema or --add-dir + expect(call.args).not.toContain('--output-schema'); + expect(call.args).not.toContain('--add-dir'); + }); + it('overrides approval policy when MCP auto-approval is enabled', async () => { // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), // approval policy is bypassed, not configured via --config @@ -247,6 +271,12 @@ describe('codex-provider.ts', () => { it('uses the SDK when no tools are requested and an API key is present', async () => { process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + // Override auth indicators so CLI-native auth doesn't take priority over API key + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' }); const results = await collectAsyncGenerator( @@ -264,6 +294,12 @@ describe('codex-provider.ts', () => { it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => { process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + // Override auth indicators so CLI-native auth doesn't take priority over API key + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); await collectAsyncGenerator( @@ -308,8 +344,10 @@ describe('codex-provider.ts', () => { ); const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; - // High reasoning effort should have 3x the default timeout (90000ms) - expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + // High reasoning effort should have 3x the CLI base timeout (120000ms) + // CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); }); it('passes extended timeout for xhigh reasoning effort', async () => { @@ -345,8 +383,10 @@ describe('codex-provider.ts', () => { ); const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; - // No reasoning effort should use the default timeout - expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS); + // No reasoning effort should use the CLI base timeout (2 minutes) + // CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS); }); }); diff --git a/apps/server/tests/unit/providers/copilot-provider.test.ts b/apps/server/tests/unit/providers/copilot-provider.test.ts index ccd7ae28..55db34df 100644 --- a/apps/server/tests/unit/providers/copilot-provider.test.ts +++ b/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -1,17 +1,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { CopilotClient } from '@github/copilot-sdk'; + +const createSessionMock = vi.fn(); +const resumeSessionMock = vi.fn(); + +function createMockSession(sessionId = 'test-session') { + let eventHandler: ((event: any) => void) | null = null; + return { + sessionId, + send: vi.fn().mockImplementation(async () => { + if (eventHandler) { + eventHandler({ type: 'assistant.message', data: { content: 'hello' } }); + eventHandler({ type: 'session.idle' }); + } + }), + destroy: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockImplementation((handler: (event: any) => void) => { + eventHandler = handler; + }), + }; +} // Mock the Copilot SDK vi.mock('@github/copilot-sdk', () => ({ CopilotClient: vi.fn().mockImplementation(() => ({ start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), - createSession: vi.fn().mockResolvedValue({ - sessionId: 'test-session', - send: vi.fn().mockResolvedValue(undefined), - destroy: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - }), + createSession: createSessionMock, + resumeSession: resumeSessionMock, })), })); @@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(CopilotClient).mockImplementation(function () { + return { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: createSessionMock, + resumeSession: resumeSessionMock, + } as any; + }); + createSessionMock.mockResolvedValue(createMockSession()); + resumeSessionMock.mockResolvedValue(createMockSession('resumed-session')); // Mock fs.existsSync for CLI path validation vi.mocked(fs.existsSync).mockReturnValue(true); @@ -369,6 +397,45 @@ describe('copilot-provider.ts', () => { }); }); + it('should use error code in fallback when session.error message is empty', () => { + const event = { + type: 'session.error', + data: { message: '', code: 'RATE_LIMIT_EXCEEDED' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toContain('RATE_LIMIT_EXCEEDED'); + expect(result!.error).not.toBe('Unknown error'); + }); + + it('should return generic "Copilot agent error" fallback when both message and code are empty', () => { + const event = { + type: 'session.error', + data: { message: '', code: '' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toBe('Copilot agent error'); + // Must NOT be the old opaque 'Unknown error' + expect(result!.error).not.toBe('Unknown error'); + }); + + it('should return generic "Copilot agent error" fallback when data has no code field', () => { + const event = { + type: 'session.error', + data: { message: '' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toBe('Copilot agent error'); + }); + it('should return null for unknown event types', () => { const event = { type: 'unknown.event' }; @@ -514,4 +581,45 @@ describe('copilot-provider.ts', () => { expect(todoInput.todos[0].status).toBe('completed'); }); }); + + describe('executeQuery resume behavior', () => { + it('uses resumeSession when sdkSessionId is provided', async () => { + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'session-123', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true); + }); + + it('falls back to createSession when resumeSession fails', async () => { + resumeSessionMock.mockRejectedValueOnce(new Error('session not found')); + createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session')); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'stale-session', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'stale-session', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true); + }); + }); }); diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts new file mode 100644 index 00000000..0e41d963 --- /dev/null +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CursorProvider } from '@/providers/cursor-provider.js'; + +describe('cursor-provider.ts', () => { + describe('buildCliArgs', () => { + it('adds --resume when sdkSessionId is provided', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Continue the task', + model: 'gpt-5', + cwd: '/tmp/project', + sdkSessionId: 'cursor-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-session-123'); + }); + + it('does not add --resume when sdkSessionId is omitted', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Start a new task', + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); + + describe('normalizeEvent - result error handling', () => { + let provider: CursorProvider; + + beforeEach(() => { + provider = Object.create(CursorProvider.prototype) as CursorProvider; + }); + + it('returns error message from resultEvent.error when is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: 'Rate limit exceeded', + result: '', + subtype: 'error', + duration_ms: 3000, + session_id: 'sess-123', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toBe('Rate limit exceeded'); + }); + + it('falls back to resultEvent.result when error field is empty and is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: 'Process terminated unexpectedly', + subtype: 'error', + duration_ms: 5000, + session_id: 'sess-456', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toBe('Process terminated unexpectedly'); + }); + + it('builds diagnostic fallback when both error and result are empty and is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: '', + subtype: 'error', + duration_ms: 5000, + session_id: 'sess-789', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + // Should contain diagnostic info rather than 'Unknown error' + expect(msg!.error).toContain('5000ms'); + expect(msg!.error).toContain('sess-789'); + expect(msg!.error).not.toBe('Unknown error'); + }); + + it('preserves session_id in error message', () => { + const event = { + type: 'result', + is_error: true, + error: 'Timeout occurred', + result: '', + subtype: 'error', + duration_ms: 30000, + session_id: 'my-session-id', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg!.session_id).toBe('my-session-id'); + }); + + it('uses "none" when session_id is missing from diagnostic fallback', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: '', + subtype: 'error', + duration_ms: 5000, + // session_id intentionally omitted + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toContain('none'); + expect(msg!.error).not.toContain('undefined'); + }); + + it('returns success result when is_error=false', () => { + const event = { + type: 'result', + is_error: false, + error: '', + result: 'Completed successfully', + subtype: 'success', + duration_ms: 2000, + session_id: 'sess-ok', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('result'); + expect(msg!.subtype).toBe('success'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/gemini-provider.test.ts b/apps/server/tests/unit/providers/gemini-provider.test.ts new file mode 100644 index 00000000..9a29c765 --- /dev/null +++ b/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; +import type { ProviderMessage } from '@automaker/types'; + +describe('gemini-provider.ts', () => { + let provider: GeminiProvider; + + beforeEach(() => { + provider = new GeminiProvider(); + }); + + describe('buildCliArgs', () => { + it('should include --prompt with empty string to force headless mode', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello from Gemini', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const promptIndex = args.indexOf('--prompt'); + expect(promptIndex).toBeGreaterThan(-1); + expect(args[promptIndex + 1]).toBe(''); + }); + + it('should include --resume when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + sdkSessionId: 'gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('gemini-session-123'); + }); + + it('should not include --resume when sdkSessionId is missing', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + + it('should include --sandbox false for faster execution', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const sandboxIndex = args.indexOf('--sandbox'); + expect(sandboxIndex).toBeGreaterThan(-1); + expect(args[sandboxIndex + 1]).toBe('false'); + }); + + it('should include --approval-mode yolo for non-interactive use', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const approvalIndex = args.indexOf('--approval-mode'); + expect(approvalIndex).toBeGreaterThan(-1); + expect(args[approvalIndex + 1]).toBe('yolo'); + }); + + it('should include --output-format stream-json', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const formatIndex = args.indexOf('--output-format'); + expect(formatIndex).toBeGreaterThan(-1); + expect(args[formatIndex + 1]).toBe('stream-json'); + }); + + it('should include --include-directories with cwd', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/my-project', + }); + + const dirIndex = args.indexOf('--include-directories'); + expect(dirIndex).toBeGreaterThan(-1); + expect(args[dirIndex + 1]).toBe('/tmp/my-project'); + }); + + it('should add gemini- prefix to bare model names', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-2.5-flash'); + }); + + it('should not double-prefix model names that already have gemini-', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'gemini-2.5-pro', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-2.5-pro'); + }); + }); + + describe('normalizeEvent - error handling', () => { + it('returns error from result event when status=error and error field is set', () => { + const event = { + type: 'result', + status: 'error', + error: 'Model overloaded', + session_id: 'sess-gemini-1', + stats: { duration_ms: 4000, total_tokens: 0 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toBe('Model overloaded'); + expect(msg.session_id).toBe('sess-gemini-1'); + }); + + it('builds diagnostic fallback when result event has status=error but empty error field', () => { + const event = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-gemini-2', + stats: { duration_ms: 7500, total_tokens: 0 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + // Diagnostic info should be present instead of 'Unknown error' + expect(msg.error).toContain('7500ms'); + expect(msg.error).toContain('sess-gemini-2'); + expect(msg.error).not.toBe('Unknown error'); + }); + + it('builds fallback with "unknown" duration when stats are missing', () => { + const event = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-gemini-nostats', + // no stats field + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toContain('unknown'); + }); + + it('returns error from standalone error event with error field set', () => { + const event = { + type: 'error', + error: 'API key invalid', + session_id: 'sess-gemini-3', + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toBe('API key invalid'); + }); + + it('builds diagnostic fallback when standalone error event has empty error field', () => { + const event = { + type: 'error', + error: '', + session_id: 'sess-gemini-empty', + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + // Should include session_id, not just 'Unknown error' + expect(msg.error).toContain('sess-gemini-empty'); + expect(msg.error).not.toBe('Unknown error'); + }); + + it('builds fallback mentioning "none" when session_id is missing from error event', () => { + const event = { + type: 'error', + error: '', + // no session_id + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toContain('none'); + }); + + it('uses consistent "Gemini agent failed" label for both result and error event fallbacks', () => { + const resultEvent = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-r', + stats: { duration_ms: 1000 }, + }; + const errorEvent = { + type: 'error', + error: '', + session_id: 'sess-e', + }; + + const resultMsg = provider.normalizeEvent(resultEvent) as ProviderMessage; + const errorMsg = provider.normalizeEvent(errorEvent) as ProviderMessage; + + // Both fallback messages should use the same "Gemini agent failed" prefix + expect(resultMsg.error).toContain('Gemini agent failed'); + expect(errorMsg.error).toContain('Gemini agent failed'); + }); + + it('returns success result when result event has status=success', () => { + const event = { + type: 'result', + status: 'success', + error: '', + session_id: 'sess-gemini-ok', + stats: { duration_ms: 1200, total_tokens: 500 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('result'); + expect(msg.subtype).toBe('success'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index 641838ef..a3a0d726 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -69,19 +69,19 @@ describe('opencode-provider.ts', () => { it('should include free tier GLM model', () => { const models = provider.getAvailableModels(); - const glm = models.find((m) => m.id === 'opencode/glm-4.7-free'); + const glm = models.find((m) => m.id === 'opencode/glm-5-free'); expect(glm).toBeDefined(); - expect(glm?.name).toBe('GLM 4.7 Free'); + expect(glm?.name).toBe('GLM 5 Free'); expect(glm?.tier).toBe('basic'); }); it('should include free tier MiniMax model', () => { const models = provider.getAvailableModels(); - const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free'); + const minimax = models.find((m) => m.id === 'opencode/minimax-m2.5-free'); expect(minimax).toBeDefined(); - expect(minimax?.name).toBe('MiniMax M2.1 Free'); + expect(minimax?.name).toBe('MiniMax M2.5 Free'); expect(minimax?.tier).toBe('basic'); }); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index fbf01e90..f92c7256 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -54,13 +54,13 @@ describe('provider-factory.ts', () => { describe('getProviderForModel', () => { describe('Claude models (claude-* prefix)', () => { - it('should return ClaudeProvider for claude-opus-4-5-20251101', () => { - const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); + it('should return ClaudeProvider for claude-opus-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it('should return ClaudeProvider for claude-sonnet-4-20250514', () => { - const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-20250514'); + it('should return ClaudeProvider for claude-sonnet-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); @@ -70,7 +70,7 @@ describe('provider-factory.ts', () => { }); it('should be case-insensitive for claude models', () => { - const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101'); + const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); }); diff --git a/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts b/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts new file mode 100644 index 00000000..92556a35 --- /dev/null +++ b/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BacklogPlanResult, ProviderMessage } from '@automaker/types'; + +const { + mockGetAll, + mockExecuteQuery, + mockSaveBacklogPlan, + mockSetRunningState, + mockSetRunningDetails, + mockGetPromptCustomization, + mockGetAutoLoadClaudeMdSetting, + mockGetUseClaudeCodeSystemPromptSetting, +} = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockExecuteQuery: vi.fn(), + mockSaveBacklogPlan: vi.fn(), + mockSetRunningState: vi.fn(), + mockSetRunningDetails: vi.fn(), + mockGetPromptCustomization: vi.fn(), + mockGetAutoLoadClaudeMdSetting: vi.fn(), + mockGetUseClaudeCodeSystemPromptSetting: vi.fn(), +})); + +vi.mock('@/services/feature-loader.js', () => ({ + FeatureLoader: class { + getAll = mockGetAll; + }, +})); + +vi.mock('@/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderForModel: vi.fn(() => ({ + executeQuery: mockExecuteQuery, + })), + }, +})); + +vi.mock('@/routes/backlog-plan/common.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + setRunningState: mockSetRunningState, + setRunningDetails: mockSetRunningDetails, + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + saveBacklogPlan: mockSaveBacklogPlan, +})); + +vi.mock('@/lib/settings-helpers.js', () => ({ + getPromptCustomization: mockGetPromptCustomization, + getAutoLoadClaudeMdSetting: mockGetAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting: mockGetUseClaudeCodeSystemPromptSetting, + getPhaseModelWithOverrides: vi.fn(), +})); + +import { generateBacklogPlan } from '@/routes/backlog-plan/generate-plan.js'; + +function createMockEvents() { + return { + emit: vi.fn(), + }; +} + +describe('generateBacklogPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockGetAll.mockResolvedValue([]); + mockGetPromptCustomization.mockResolvedValue({ + backlogPlan: { + systemPrompt: 'System instructions', + userPromptTemplate: + 'Current features:\n{{currentFeatures}}\n\nUser request:\n{{userRequest}}', + }, + }); + mockGetAutoLoadClaudeMdSetting.mockResolvedValue(false); + mockGetUseClaudeCodeSystemPromptSetting.mockResolvedValue(true); + }); + + it('salvages valid streamed JSON when Claude process exits with code 1', async () => { + const partialResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add signup form', + description: 'Create signup UI and validation', + category: 'frontend', + }, + reason: 'Required for user onboarding', + }, + ], + summary: 'Adds signup feature to the backlog', + dependencyUpdates: [], + }; + + const responseJson = JSON.stringify(partialResult); + + async function* streamWithExitError(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: responseJson }], + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithExitError()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Please add a signup feature', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual(partialResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + prompt: 'Please add a signup feature', + model: 'claude-opus-4-6', + result: partialResult, + }) + ); + expect(events.emit).toHaveBeenCalledWith('backlog-plan:event', { + type: 'backlog_plan_complete', + result: partialResult, + }); + expect(mockSetRunningState).toHaveBeenCalledWith(false, null); + expect(mockSetRunningDetails).toHaveBeenCalledWith(null); + }); + + it('prefers parseable provider result over longer non-JSON accumulated text on exit', async () => { + const recoveredResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add reset password flow', + description: 'Implement reset password request and token validation UI', + category: 'frontend', + }, + reason: 'Supports account recovery', + }, + ], + summary: 'Adds password reset capability', + dependencyUpdates: [], + }; + + const validProviderResult = JSON.stringify(recoveredResult); + const invalidAccumulatedText = `${validProviderResult}\n\nAdditional commentary that breaks raw JSON parsing.`; + + async function* streamWithResultThenExit(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: invalidAccumulatedText }], + }, + }; + yield { + type: 'result', + subtype: 'success', + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: validProviderResult, + session_id: 'session-1', + total_cost_usd: 0, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 10, + server_tool_use: { + web_search_requests: 0, + }, + service_tier: 'standard', + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithResultThenExit()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Add password reset support', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(result).toEqual(recoveredResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + result: recoveredResult, + }) + ); + }); +}); diff --git a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts index 2cd868c6..c599fd07 100644 --- a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts +++ b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts @@ -1,27 +1,15 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Request, Response } from 'express'; import { createMockExpressContext } from '../../../utils/mocks.js'; -vi.mock('child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - exec: vi.fn(), - }; -}); +vi.mock('@/services/worktree-branch-service.js', () => ({ + performSwitchBranch: vi.fn(), +})); -vi.mock('util', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - promisify: (fn: unknown) => fn, - }; -}); - -import { exec } from 'child_process'; +import { performSwitchBranch } from '@/services/worktree-branch-service.js'; import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js'; -const mockExec = exec as Mock; +const mockPerformSwitchBranch = vi.mocked(performSwitchBranch); describe('switch-branch route', () => { let req: Request; @@ -34,26 +22,77 @@ describe('switch-branch route', () => { res = context.res; }); + it('should return 400 when branchName is missing', async () => { + req.body = { worktreePath: '/repo/path' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'branchName required', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName starts with a dash', async () => { + req.body = { worktreePath: '/repo/path', branchName: '-flag' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName starts with double dash', async () => { + req.body = { worktreePath: '/repo/path', branchName: '--option' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName contains invalid characters', async () => { + req.body = { worktreePath: '/repo/path', branchName: 'branch name with spaces' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + it('should allow switching when only untracked files exist', async () => { req.body = { worktreePath: '/repo/path', branchName: 'feature/test', }; - mockExec.mockImplementation(async (command: string) => { - if (command === 'git rev-parse --abbrev-ref HEAD') { - return { stdout: 'main\n', stderr: '' }; - } - if (command === 'git rev-parse --verify feature/test') { - return { stdout: 'abc123\n', stderr: '' }; - } - if (command === 'git status --porcelain') { - return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' }; - } - if (command === 'git checkout "feature/test"') { - return { stdout: '', stderr: '' }; - } - return { stdout: '', stderr: '' }; + mockPerformSwitchBranch.mockResolvedValue({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test'", + hasConflicts: false, + stashedChanges: false, + }, }); const handler = createSwitchBranchHandler(); @@ -65,42 +104,42 @@ describe('switch-branch route', () => { previousBranch: 'main', currentBranch: 'feature/test', message: "Switched to branch 'feature/test'", + hasConflicts: false, + stashedChanges: false, }, }); - expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' }); + expect(mockPerformSwitchBranch).toHaveBeenCalledWith('/repo/path', 'feature/test', undefined); }); - it('should block switching when tracked files are modified', async () => { + it('should stash changes and switch when tracked files are modified', async () => { req.body = { worktreePath: '/repo/path', branchName: 'feature/test', }; - mockExec.mockImplementation(async (command: string) => { - if (command === 'git rev-parse --abbrev-ref HEAD') { - return { stdout: 'main\n', stderr: '' }; - } - if (command === 'git rev-parse --verify feature/test') { - return { stdout: 'abc123\n', stderr: '' }; - } - if (command === 'git status --porcelain') { - return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; - } - if (command === 'git status --short') { - return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; - } - return { stdout: '', stderr: '' }; + mockPerformSwitchBranch.mockResolvedValue({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test' (local changes stashed and reapplied)", + hasConflicts: false, + stashedChanges: true, + }, }); const handler = createSwitchBranchHandler(); await handler(req, res); - expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ - success: false, - error: - 'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.', - code: 'UNCOMMITTED_CHANGES', + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test' (local changes stashed and reapplied)", + hasConflicts: false, + stashedChanges: true, + }, }); }); }); diff --git a/apps/server/tests/unit/services/agent-executor.test.ts b/apps/server/tests/unit/services/agent-executor.test.ts new file mode 100644 index 00000000..e47b1a01 --- /dev/null +++ b/apps/server/tests/unit/services/agent-executor.test.ts @@ -0,0 +1,1238 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + AgentExecutor, + type AgentExecutionOptions, + type AgentExecutionResult, + type WaitForApprovalFn, + type SaveFeatureSummaryFn, + type UpdateFeatureSummaryFn, + type BuildTaskPromptFn, +} from '../../../src/services/agent-executor.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { BaseProvider } from '../../../src/providers/base-provider.js'; + +/** + * Unit tests for AgentExecutor + * + * Note: Full integration tests for execute() require complex mocking of + * @automaker/utils and @automaker/platform which have module hoisting issues. + * These tests focus on: + * - Constructor injection + * - Interface exports + * - Type correctness + * + * Integration tests for streaming/marker detection are covered in E2E tests + * and auto-mode-service tests. + */ +describe('AgentExecutor', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockPlanApprovalService: PlanApprovalService; + let mockSettingsService: SettingsService | null; + + beforeEach(() => { + // Reset mocks + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateTaskStatus: vi.fn().mockResolvedValue(undefined), + updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined), + saveFeatureSummary: vi.fn().mockResolvedValue(undefined), + } as unknown as FeatureStateManager; + + mockPlanApprovalService = { + waitForApproval: vi.fn(), + } as unknown as PlanApprovalService; + + mockSettingsService = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should accept null settingsService', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should accept undefined settingsService', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should store eventBus dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + // Verify executor was created - actual use tested via execute() + expect(executor).toBeDefined(); + }); + + it('should store featureStateManager dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeDefined(); + }); + + it('should store planApprovalService dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeDefined(); + }); + }); + + describe('interface exports', () => { + it('should export AgentExecutionOptions type', () => { + // Type assertion test - if this compiles, the type is exported correctly + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + }; + expect(options.featureId).toBe('test-feature'); + }); + + it('should export AgentExecutionResult type', () => { + const result: AgentExecutionResult = { + responseText: 'test response', + specDetected: false, + tasksCompleted: 0, + aborted: false, + }; + expect(result.aborted).toBe(false); + }); + + it('should export callback types', () => { + const waitForApproval: WaitForApprovalFn = async () => ({ approved: true }); + const saveFeatureSummary: SaveFeatureSummaryFn = async () => {}; + const updateFeatureSummary: UpdateFeatureSummaryFn = async () => {}; + const buildTaskPrompt: BuildTaskPromptFn = () => 'prompt'; + + expect(typeof waitForApproval).toBe('function'); + expect(typeof saveFeatureSummary).toBe('function'); + expect(typeof updateFeatureSummary).toBe('function'); + expect(typeof buildTaskPrompt).toBe('function'); + }); + }); + + describe('AgentExecutionOptions', () => { + it('should accept required options', () => { + const options: AgentExecutionOptions = { + workDir: '/test/workdir', + featureId: 'feature-123', + prompt: 'Test prompt', + projectPath: '/test/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + }; + + expect(options.workDir).toBe('/test/workdir'); + expect(options.featureId).toBe('feature-123'); + expect(options.prompt).toBe('Test prompt'); + expect(options.projectPath).toBe('/test/project'); + expect(options.abortController).toBeInstanceOf(AbortController); + expect(options.effectiveBareModel).toBe('claude-sonnet-4-6'); + }); + + it('should accept optional options', () => { + const options: AgentExecutionOptions = { + workDir: '/test/workdir', + featureId: 'feature-123', + prompt: 'Test prompt', + projectPath: '/test/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + // Optional fields + imagePaths: ['/image1.png', '/image2.png'], + model: 'claude-sonnet-4-6', + planningMode: 'spec', + requirePlanApproval: true, + previousContent: 'Previous content', + systemPrompt: 'System prompt', + autoLoadClaudeMd: true, + thinkingLevel: 'medium', + branchName: 'feature-branch', + specAlreadyDetected: false, + existingApprovedPlanContent: 'Approved plan', + persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' }], + sdkOptions: { + maxTurns: 100, + allowedTools: ['read', 'write'], + }, + }; + + expect(options.imagePaths).toHaveLength(2); + expect(options.planningMode).toBe('spec'); + expect(options.requirePlanApproval).toBe(true); + expect(options.branchName).toBe('feature-branch'); + }); + }); + + describe('AgentExecutionResult', () => { + it('should contain responseText', () => { + const result: AgentExecutionResult = { + responseText: 'Full response text from agent', + specDetected: true, + tasksCompleted: 5, + aborted: false, + }; + expect(result.responseText).toBe('Full response text from agent'); + }); + + it('should contain specDetected flag', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: true, + tasksCompleted: 0, + aborted: false, + }; + expect(result.specDetected).toBe(true); + }); + + it('should contain tasksCompleted count', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: true, + tasksCompleted: 10, + aborted: false, + }; + expect(result.tasksCompleted).toBe(10); + }); + + it('should contain aborted flag', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: false, + tasksCompleted: 3, + aborted: true, + }; + expect(result.aborted).toBe(true); + }); + }); + + describe('execute method signature', () => { + it('should have execute method', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(typeof executor.execute).toBe('function'); + }); + + it('should accept options and callbacks', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Type check - verifying the signature accepts the expected parameters + // Actual execution would require mocking external modules + const executeSignature = executor.execute.length; + // execute(options, callbacks) = 2 parameters + expect(executeSignature).toBe(2); + }); + }); + + describe('callback types', () => { + it('WaitForApprovalFn should return approval result', async () => { + const waitForApproval: WaitForApprovalFn = vi.fn().mockResolvedValue({ + approved: true, + feedback: 'Looks good', + editedPlan: undefined, + }); + + const result = await waitForApproval('feature-123', '/project'); + expect(result.approved).toBe(true); + expect(result.feedback).toBe('Looks good'); + }); + + it('WaitForApprovalFn should handle rejection with feedback', async () => { + const waitForApproval: WaitForApprovalFn = vi.fn().mockResolvedValue({ + approved: false, + feedback: 'Please add more tests', + editedPlan: '## Revised Plan\n...', + }); + + const result = await waitForApproval('feature-123', '/project'); + expect(result.approved).toBe(false); + expect(result.feedback).toBe('Please add more tests'); + expect(result.editedPlan).toBeDefined(); + }); + + it('SaveFeatureSummaryFn should accept parameters', async () => { + const saveSummary: SaveFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + + await saveSummary('/project', 'feature-123', 'Feature summary text'); + expect(saveSummary).toHaveBeenCalledWith('/project', 'feature-123', 'Feature summary text'); + }); + + it('UpdateFeatureSummaryFn should accept parameters', async () => { + const updateSummary: UpdateFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + + await updateSummary('/project', 'feature-123', 'Updated summary'); + expect(updateSummary).toHaveBeenCalledWith('/project', 'feature-123', 'Updated summary'); + }); + + it('BuildTaskPromptFn should return prompt string', () => { + const buildPrompt: BuildTaskPromptFn = vi.fn().mockReturnValue('Execute T001: Create file'); + + const task = { id: 'T001', description: 'Create file', status: 'pending' as const }; + const allTasks = [task]; + const prompt = buildPrompt(task, allTasks, 0, 'Plan content', 'Template', undefined); + + expect(typeof prompt).toBe('string'); + expect(prompt).toBe('Execute T001: Create file'); + }); + }); + + describe('dependency injection patterns', () => { + it('should allow different eventBus implementations', () => { + const customEventBus = { + emitAutoModeEvent: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + } as unknown as TypedEventBus; + + const executor = new AgentExecutor( + customEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should allow different featureStateManager implementations', () => { + const customStateManager = { + updateTaskStatus: vi.fn().mockResolvedValue(undefined), + updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined), + saveFeatureSummary: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(null), + } as unknown as FeatureStateManager; + + const executor = new AgentExecutor( + mockEventBus, + customStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should work with mock settingsService', () => { + const customSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + customSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + }); + + describe('execute() behavior', () => { + /** + * Execution tests focus on verifiable behaviors without requiring + * full stream mocking. Complex integration scenarios are tested in E2E. + */ + + it('should return aborted=true when abort signal is already aborted', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Create an already-aborted controller + const abortController = new AbortController(); + abortController.abort(); + + // Mock provider that yields nothing (would check signal first) + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Generator yields nothing, simulating immediate abort check + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController, + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Execute - should complete without error even with aborted signal + const result = await executor.execute(options, callbacks); + + // When stream is empty and signal is aborted before stream starts, + // the result depends on whether abort was checked + expect(result).toBeDefined(); + expect(result.responseText).toBeDefined(); + }); + + it('should initialize with previousContent when provided', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Empty stream + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + previousContent: 'Previous context from earlier session', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // Response should start with previous content + expect(result.responseText).toContain('Previous context from earlier session'); + expect(result.responseText).toContain('Follow-up Session'); + }); + + it('should return specDetected=false when no spec markers in content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Simple response without spec markers' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', // No spec detection in skip mode + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + expect(result.specDetected).toBe(false); + expect(result.responseText).toContain('Simple response without spec markers'); + }); + + it('should emit auto_mode_progress events for text content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'First chunk of text' }], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Second chunk of text' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should emit progress events for each text chunk + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_progress', { + featureId: 'test-feature', + branchName: null, + content: 'First chunk of text', + }); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_progress', { + featureId: 'test-feature', + branchName: null, + content: 'Second chunk of text', + }); + }); + + it('should emit auto_mode_tool events for tool use', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + name: 'write_file', + input: { path: '/test/file.ts', content: 'test content' }, + }, + ], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should emit tool event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_tool', { + featureId: 'test-feature', + branchName: null, + tool: 'write_file', + input: { path: '/test/file.ts', content: 'test content' }, + }); + }); + + it('should throw error when provider stream yields error message', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Starting...' }], + }, + }; + yield { + type: 'error', + error: 'API rate limit exceeded', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('API rate limit exceeded'); + }); + + it('should throw "Unknown error" when provider stream yields error with empty message', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'error', + error: '', + session_id: 'sess-123', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('Unknown error'); + }); + + it('should throw with sanitized error when provider yields ANSI-decorated error', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'error', + // ANSI color codes + "Error: " prefix that should be stripped + error: '\x1b[31mError: Connection refused\x1b[0m', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Should strip ANSI codes and "Error: " prefix + await expect(executor.execute(options, callbacks)).rejects.toThrow('Connection refused'); + }); + + it('should throw when result subtype is error_max_turns', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Working on it...' }], + }, + }; + yield { + type: 'result', + subtype: 'error_max_turns', + session_id: 'sess-456', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_turns' + ); + }); + + it('should throw when result subtype is error_during_execution', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_during_execution', + session_id: 'sess-789', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_during_execution' + ); + }); + + it('should throw when result subtype is error_max_structured_output_retries', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_max_structured_output_retries', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_structured_output_retries' + ); + }); + + it('should throw when result subtype is error_max_budget_usd', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_max_budget_usd', + session_id: 'sess-budget', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_budget_usd' + ); + }); + + it('should NOT throw when result subtype is success', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Done!' }], + }, + }; + yield { + type: 'result', + subtype: 'success', + session_id: 'sess-ok', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Should resolve without throwing + const result = await executor.execute(options, callbacks); + expect(result.aborted).toBe(false); + expect(result.responseText).toContain('Done!'); + }); + + it('should throw error when authentication fails in response', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Error: Invalid API key' }], + }, + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('Authentication failed'); + }); + + it('should accumulate responseText from multiple text blocks', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Part 1.' }, + { type: 'text', text: ' Part 2.' }, + ], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: ' Part 3.' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // All parts should be in response text + expect(result.responseText).toContain('Part 1'); + expect(result.responseText).toContain('Part 2'); + expect(result.responseText).toContain('Part 3'); + }); + + it('should return tasksCompleted=0 when no tasks executed', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Simple response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + expect(result.tasksCompleted).toBe(0); + expect(result.aborted).toBe(false); + }); + + it('should pass branchName to event payloads', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + branchName: 'feature/my-feature', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Branch name should be passed to progress event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_progress', + expect.objectContaining({ + branchName: 'feature/my-feature', + }) + ); + }); + + it('should return correct result structure', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Test response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // Verify result has all expected properties + expect(result).toHaveProperty('responseText'); + expect(result).toHaveProperty('specDetected'); + expect(result).toHaveProperty('tasksCompleted'); + expect(result).toHaveProperty('aborted'); + + // Verify types + expect(typeof result.responseText).toBe('string'); + expect(typeof result.specDetected).toBe('boolean'); + expect(typeof result.tasksCompleted).toBe('number'); + expect(typeof result.aborted).toBe('boolean'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-output-validation.test.ts b/apps/server/tests/unit/services/agent-output-validation.test.ts new file mode 100644 index 00000000..69392469 --- /dev/null +++ b/apps/server/tests/unit/services/agent-output-validation.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Contract tests verifying the tool marker format used by agent-executor + * (which writes agent output) and execution-service (which reads it to + * determine if the agent did meaningful work). + * + * The agent-executor writes: `\n🔧 Tool: ${block.name}\n` + * The execution-service checks: `agentOutput.includes('🔧 Tool:')` + * + * These tests ensure the marker format contract stays consistent and + * document the exact detection logic used for status determination. + */ + +// The exact marker prefix that execution-service searches for +const TOOL_MARKER = '🔧 Tool:'; + +// Minimum output length threshold for "meaningful work" +const MIN_OUTPUT_LENGTH = 200; + +/** + * Simulates the agent-executor's tool_use output format. + * See: agent-executor.ts line ~293 + */ +function formatToolUseBlock(toolName: string, input?: Record): string { + let output = `\n${TOOL_MARKER} ${toolName}\n`; + if (input) output += `Input: ${JSON.stringify(input, null, 2)}\n`; + return output; +} + +/** + * Simulates the execution-service's output validation logic. + * See: execution-service.ts lines ~427-429 + */ +function validateAgentOutput( + agentOutput: string, + skipTests: boolean +): 'verified' | 'waiting_approval' { + const hasToolUsage = agentOutput.includes(TOOL_MARKER); + const hasMinimalOutput = agentOutput.trim().length < MIN_OUTPUT_LENGTH; + const agentDidWork = hasToolUsage && !hasMinimalOutput; + + if (skipTests) return 'waiting_approval'; + if (!agentDidWork) return 'waiting_approval'; + return 'verified'; +} + +describe('Agent Output Validation - Contract Tests', () => { + describe('tool marker format contract', () => { + it('agent-executor tool format contains the expected marker', () => { + const toolOutput = formatToolUseBlock('Read', { file_path: '/src/index.ts' }); + expect(toolOutput).toContain(TOOL_MARKER); + }); + + it('agent-executor tool format includes tool name after marker', () => { + const toolOutput = formatToolUseBlock('Edit', { + file_path: '/src/app.ts', + old_string: 'foo', + new_string: 'bar', + }); + expect(toolOutput).toContain('🔧 Tool: Edit'); + }); + + it('agent-executor tool format includes JSON input', () => { + const input = { file_path: '/src/index.ts' }; + const toolOutput = formatToolUseBlock('Read', input); + expect(toolOutput).toContain('Input: '); + expect(toolOutput).toContain('"file_path": "/src/index.ts"'); + }); + + it('agent-executor tool format works without input', () => { + const toolOutput = formatToolUseBlock('Bash'); + expect(toolOutput).toContain('🔧 Tool: Bash'); + expect(toolOutput).not.toContain('Input:'); + }); + + it('marker includes colon and space to avoid false positives', () => { + // Ensure the marker is specific enough to avoid matching other emoji patterns + expect(TOOL_MARKER).toBe('🔧 Tool:'); + expect(TOOL_MARKER).toContain(':'); + }); + }); + + describe('output validation logic', () => { + it('verified: tool usage + sufficient output', () => { + const output = + 'Starting implementation of the new feature...\n' + + formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + + 'I can see the existing code. Let me make the needed changes.\n' + + formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) + + 'Changes complete. The implementation adds new validation logic and tests.'; + expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('waiting_approval: no tool markers regardless of length', () => { + const longOutput = 'I analyzed the codebase. '.repeat(50); + expect(longOutput.trim().length).toBeGreaterThan(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(longOutput, false)).toBe('waiting_approval'); + }); + + it('waiting_approval: tool markers but insufficient length', () => { + const shortOutput = formatToolUseBlock('Read', { file_path: '/src/a.ts' }); + expect(shortOutput.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(shortOutput, false)).toBe('waiting_approval'); + }); + + it('waiting_approval: empty output', () => { + expect(validateAgentOutput('', false)).toBe('waiting_approval'); + }); + + it('waiting_approval: skipTests always overrides', () => { + const goodOutput = + 'Starting...\n' + + formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + + formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) + + 'Done implementing. '.repeat(15); + expect(goodOutput.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(goodOutput, true)).toBe('waiting_approval'); + }); + + it('boundary: exactly MIN_OUTPUT_LENGTH chars with tool is verified', () => { + const tool = formatToolUseBlock('Read'); + const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - tool.trim().length); + const output = tool + padding; + expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('boundary: MIN_OUTPUT_LENGTH - 1 chars with tool is waiting_approval', () => { + const marker = `${TOOL_MARKER} Read\n`; + const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - 1 - marker.length); + const output = marker + padding; + expect(output.trim().length).toBe(MIN_OUTPUT_LENGTH - 1); + + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + }); + + describe('realistic provider scenarios', () => { + it('Claude SDK agent with multiple tools → verified', () => { + let output = "I'll implement the feature.\n\n"; + output += formatToolUseBlock('Read', { file_path: '/src/components/App.tsx' }); + output += 'I see the component. Let me update it.\n\n'; + output += formatToolUseBlock('Edit', { + file_path: '/src/components/App.tsx', + old_string: 'const App = () => {', + new_string: 'const App: React.FC = () => {', + }); + output += 'Done. The component is now typed correctly.\n'; + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('Cursor CLI quick exit (no tools) → waiting_approval', () => { + const output = 'Task received. Processing...\nResult: completed successfully.'; + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Codex CLI with brief acknowledgment → waiting_approval', () => { + const output = 'Understood the task. Starting implementation.\nDone.'; + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Agent that only reads but makes no edits (single Read tool, short output) → waiting_approval', () => { + const output = formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + 'File read.'; + expect(output.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH); + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Agent with extensive tool usage and explanation → verified', () => { + let output = 'Analyzing the codebase for the authentication feature.\n\n'; + for (let i = 0; i < 5; i++) { + output += formatToolUseBlock('Read', { file_path: `/src/auth/handler${i}.ts` }); + output += `Found handler ${i}. `; + } + output += formatToolUseBlock('Edit', { + file_path: '/src/auth/handler0.ts', + old_string: 'function login() {}', + new_string: 'async function login(creds: Credentials) { ... }', + }); + output += 'Implementation complete with all authentication changes applied.\n'; + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index fed1eae3..22ab6383 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -123,9 +123,10 @@ describe('agent-service.ts', () => { }); expect(result.success).toBe(true); - // First call reads session file, metadata file, and queue state file (3 calls) + // First call reads metadata file and session file via ensureSession (2 calls) + // Since no metadata or messages exist, a fresh session is created without loading queue state. // Second call should reuse in-memory session (no additional calls) - expect(fs.readFile).toHaveBeenCalledTimes(3); + expect(fs.readFile).toHaveBeenCalledTimes(2); }); }); @@ -187,6 +188,125 @@ describe('agent-service.ts', () => { expect(mockEvents.emit).toHaveBeenCalled(); }); + it('should emit tool_result events from provider stream', async () => { + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* () { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', + tool_use_id: 'tool-1', + input: { file_path: 'README.md' }, + }, + ], + }, + }; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'File contents here', + }, + ], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + }); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'agent:stream', + expect.objectContaining({ + sessionId: 'session-1', + type: 'tool_result', + tool: { + name: 'Read', + input: { + toolUseId: 'tool-1', + content: 'File contents here', + }, + }, + }) + ); + }); + + it('should emit tool_result with unknown tool name for unregistered tool_use_id', async () => { + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* () { + // Yield tool_result WITHOUT a preceding tool_use (unregistered tool_use_id) + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'unregistered-id', + content: 'Some result content', + }, + ], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + }); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'agent:stream', + expect.objectContaining({ + sessionId: 'session-1', + type: 'tool_result', + tool: { + name: 'unknown', + input: { + toolUseId: 'unregistered-id', + content: 'Some result content', + }, + }, + }) + ); + }); + it('should handle images in message', async () => { const mockProvider = { getName: () => 'claude', @@ -271,10 +391,10 @@ describe('agent-service.ts', () => { await service.sendMessage({ sessionId: 'session-1', message: 'Hello', - model: 'claude-sonnet-4-20250514', + model: 'claude-sonnet-4-6', }); - expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514'); + expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-6'); }); it('should save session messages', async () => { @@ -302,6 +422,36 @@ describe('agent-service.ts', () => { expect(fs.writeFile).toHaveBeenCalled(); }); + + it('should include context/history preparation for Gemini requests', async () => { + let capturedOptions: any; + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* (options: any) { + capturedOptions = options; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini'); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + model: 'gemini-2.5-flash', + }); + + expect(contextLoader.loadContextFiles).toHaveBeenCalled(); + expect(capturedOptions).toBeDefined(); + }); }); describe('stopExecution', () => { @@ -330,15 +480,18 @@ describe('agent-service.ts', () => { sessionId: 'session-1', }); - const history = service.getHistory('session-1'); + const history = await service.getHistory('session-1'); expect(history).toBeDefined(); expect(history?.messages).toEqual([]); }); - it('should handle non-existent session', () => { - const history = service.getHistory('nonexistent'); - expect(history).toBeDefined(); // Returns error object + it('should handle non-existent session', async () => { + const history = await service.getHistory('nonexistent'); + expect(history).toBeDefined(); + expect(history.success).toBe(false); + expect(history.error).toBeDefined(); + expect(typeof history.error).toBe('string'); }); }); @@ -356,10 +509,108 @@ describe('agent-service.ts', () => { await service.clearSession('session-1'); - const history = service.getHistory('session-1'); + const history = await service.getHistory('session-1'); expect(history?.messages).toEqual([]); expect(fs.writeFile).toHaveBeenCalled(); }); + + it('should clear sdkSessionId from persisted metadata to prevent stale session errors', async () => { + // Setup: Session exists in metadata with an sdkSessionId (simulating + // a session that previously communicated with a CLI provider like OpenCode) + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sdkSessionId: 'stale-opencode-session-id', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + // Start the session (loads from disk metadata) + await service.startConversation({ + sessionId: 'session-1', + workingDirectory: '/test/dir', + }); + + // Clear the session + await service.clearSession('session-1'); + + // Verify that the LAST writeFile call to sessions-metadata.json + // (from clearSdkSessionId) has sdkSessionId removed. + // Earlier writes may still include it (e.g., from updateSessionTimestamp). + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const metadataWriteCalls = writeFileCalls.filter( + (call) => + typeof call[0] === 'string' && (call[0] as string).includes('sessions-metadata.json') + ); + + expect(metadataWriteCalls.length).toBeGreaterThan(0); + const lastMetadataWriteCall = metadataWriteCalls[metadataWriteCalls.length - 1]; + const savedMetadata = JSON.parse(lastMetadataWriteCall[1] as string); + expect(savedMetadata['session-1'].sdkSessionId).toBeUndefined(); + }); + }); + + describe('clearSdkSessionId', () => { + it('should remove sdkSessionId from persisted metadata', async () => { + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sdkSessionId: 'old-provider-session-id', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('session-1'); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + expect(writeFileCalls.length).toBeGreaterThan(0); + + const savedMetadata = JSON.parse(writeFileCalls[0][1] as string); + expect(savedMetadata['session-1'].sdkSessionId).toBeUndefined(); + expect(savedMetadata['session-1'].updatedAt).not.toBe('2024-01-01T00:00:00Z'); + }); + + it('should do nothing if session has no sdkSessionId', async () => { + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('session-1'); + + // writeFile should not have been called since there's no sdkSessionId to clear + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should do nothing if session does not exist in metadata', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('nonexistent'); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); }); describe('createSession', () => { @@ -431,13 +682,13 @@ describe('agent-service.ts', () => { it('should set model for existing session', async () => { vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}'); - const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514'); + const result = await service.setSessionModel('session-1', 'claude-sonnet-4-6'); expect(result).toBe(true); }); it('should return false for non-existent session', async () => { - const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514'); + const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-6'); expect(result).toBe(false); }); @@ -620,7 +871,7 @@ describe('agent-service.ts', () => { const result = await service.addToQueue('session-1', { message: 'Test prompt', imagePaths: ['/test/image.png'], - model: 'claude-sonnet-4-20250514', + model: 'claude-sonnet-4-6', }); expect(result.success).toBe(true); @@ -654,15 +905,15 @@ describe('agent-service.ts', () => { it('should return queue for session', async () => { await service.addToQueue('session-1', { message: 'Test prompt' }); - const result = service.getQueue('session-1'); + const result = await service.getQueue('session-1'); expect(result.success).toBe(true); expect(result.queue).toBeDefined(); expect(result.queue?.length).toBe(1); }); - it('should return error for non-existent session', () => { - const result = service.getQueue('nonexistent'); + it('should return error for non-existent session', async () => { + const result = await service.getQueue('nonexistent'); expect(result.success).toBe(false); expect(result.error).toBe('Session not found'); @@ -686,7 +937,7 @@ describe('agent-service.ts', () => { }); it('should remove prompt from queue', async () => { - const queueResult = service.getQueue('session-1'); + const queueResult = await service.getQueue('session-1'); const promptId = queueResult.queue![0].id; const result = await service.removeFromQueue('session-1', promptId); @@ -731,7 +982,7 @@ describe('agent-service.ts', () => { const result = await service.clearQueue('session-1'); expect(result.success).toBe(true); - const queueResult = service.getQueue('session-1'); + const queueResult = await service.getQueue('session-1'); expect(queueResult.queue?.length).toBe(0); expect(mockEvents.emit).toHaveBeenCalled(); }); diff --git a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts new file mode 100644 index 00000000..92239997 --- /dev/null +++ b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -0,0 +1,1053 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + AutoLoopCoordinator, + getWorktreeAutoLoopKey, + type AutoModeConfig, + type ProjectAutoLoopState, + type ExecuteFeatureFn, + type LoadPendingFeaturesFn, + type LoadAllFeaturesFn, + type SaveExecutionStateFn, + type ClearExecutionStateFn, + type ResetStuckFeaturesFn, + type IsFeatureFinishedFn, +} from '../../../src/services/auto-loop-coordinator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { Feature } from '@automaker/types'; + +describe('auto-loop-coordinator.ts', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockConcurrencyManager: ConcurrencyManager; + let mockSettingsService: SettingsService | null; + + // Callback mocks + let mockExecuteFeature: ExecuteFeatureFn; + let mockLoadPendingFeatures: LoadPendingFeaturesFn; + let mockLoadAllFeatures: LoadAllFeaturesFn; + let mockSaveExecutionState: SaveExecutionStateFn; + let mockClearExecutionState: ClearExecutionStateFn; + let mockResetStuckFeatures: ResetStuckFeaturesFn; + let mockIsFeatureFinished: IsFeatureFinishedFn; + let mockIsFeatureRunning: (featureId: string) => boolean; + + let coordinator: AutoLoopCoordinator; + + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'ready', + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockConcurrencyManager = { + getRunningCountForWorktree: vi.fn().mockResolvedValue(0), + isRunning: vi.fn().mockReturnValue(false), + } as unknown as ConcurrencyManager; + + mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + maxConcurrency: 3, + projects: [{ id: 'proj-1', path: '/test/project' }], + autoModeByWorktree: {}, + }), + } as unknown as SettingsService; + + // Callback mocks + mockExecuteFeature = vi.fn().mockResolvedValue(undefined); + mockLoadPendingFeatures = vi.fn().mockResolvedValue([]); + mockLoadAllFeatures = vi.fn().mockResolvedValue([]); + mockSaveExecutionState = vi.fn().mockResolvedValue(undefined); + mockClearExecutionState = vi.fn().mockResolvedValue(undefined); + mockResetStuckFeatures = vi.fn().mockResolvedValue(undefined); + mockIsFeatureFinished = vi.fn().mockReturnValue(false); + mockIsFeatureRunning = vi.fn().mockReturnValue(false); + + coordinator = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning, + mockLoadAllFeatures + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('getWorktreeAutoLoopKey', () => { + it('returns correct key for main worktree (null branch)', () => { + const key = getWorktreeAutoLoopKey('/test/project', null); + expect(key).toBe('/test/project::__main__'); + }); + + it('returns correct key for named branch', () => { + const key = getWorktreeAutoLoopKey('/test/project', 'feature/test-1'); + expect(key).toBe('/test/project::feature/test-1'); + }); + + it("normalizes 'main' branch to null", () => { + const key = getWorktreeAutoLoopKey('/test/project', 'main'); + expect(key).toBe('/test/project::__main__'); + }); + }); + + describe('startAutoLoopForProject', () => { + it('throws if loop already running for project/worktree', async () => { + // Start the first loop + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Try to start another - should throw + await expect(coordinator.startAutoLoopForProject('/test/project', null, 1)).rejects.toThrow( + 'Auto mode is already running for main worktree in project' + ); + }); + + it('creates ProjectAutoLoopState with correct config', async () => { + await coordinator.startAutoLoopForProject('/test/project', 'feature-branch', 2); + + const config = coordinator.getAutoLoopConfigForProject('/test/project', 'feature-branch'); + expect(config).toEqual({ + maxConcurrency: 2, + useWorktrees: true, + projectPath: '/test/project', + branchName: 'feature-branch', + }); + }); + + it('emits auto_mode_started event', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_started', { + message: 'Auto mode started with max 3 concurrent features', + projectPath: '/test/project', + branchName: null, + maxConcurrency: 3, + }); + }); + + it('calls saveExecutionState', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + expect(mockSaveExecutionState).toHaveBeenCalledWith('/test/project', null, 3); + }); + + it('resets stuck features on start', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(mockResetStuckFeatures).toHaveBeenCalledWith('/test/project'); + }); + + it('uses settings maxConcurrency when not provided', async () => { + const result = await coordinator.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(3); // from mockSettingsService + }); + + it('uses worktree-specific maxConcurrency from settings', async () => { + vi.mocked(mockSettingsService!.getGlobalSettings).mockResolvedValue({ + maxConcurrency: 5, + projects: [{ id: 'proj-1', path: '/test/project' }], + autoModeByWorktree: { + 'proj-1::__main__': { maxConcurrency: 7 }, + }, + }); + + const result = await coordinator.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(7); + }); + }); + + describe('stopAutoLoopForProject', () => { + it('aborts running loop', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('emits auto_mode_stopped event', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_stopped', { + message: 'Auto mode stopped', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('calls clearExecutionState', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockClearExecutionState).toHaveBeenCalledWith('/test/project', null); + }); + + it('returns 0 when no loop running', async () => { + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(mockClearExecutionState).not.toHaveBeenCalled(); + }); + }); + + describe('isAutoLoopRunningForProject', () => { + it('returns true when running', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(true); + }); + + it('returns false when not running', () => { + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('returns false for different worktree', async () => { + await coordinator.startAutoLoopForProject('/test/project', 'branch-a', 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', 'branch-b')).toBe(false); + }); + }); + + describe('runAutoLoopForProject', () => { + it('loads pending features each iteration', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Advance time to trigger loop iterations + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop to avoid hanging + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockLoadPendingFeatures).toHaveBeenCalled(); + }); + + it('executes features within concurrency limit', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(3000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + + it('emits idle event when no work remains (running=0, pending=0)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration and idle event + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('skips already-running features', async () => { + const feature2: Feature = { ...testFeature, id: 'feature-2' }; + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature, feature2]); + vi.mocked(mockIsFeatureRunning) + .mockReturnValueOnce(true) // feature-1 is running + .mockReturnValueOnce(false); // feature-2 is not running + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(3000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute feature-2, not feature-1 + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-2', true, true); + }); + + it('stops when aborted', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Stop immediately + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should not have executed many features + expect(mockExecuteFeature.mock.calls.length).toBeLessThanOrEqual(1); + }); + + it('waits when at capacity', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2); // At capacity for maxConcurrency=2 + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should not have executed features because at capacity + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('counts all running features (auto + manual) against concurrency limit', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // 2 manual features running — total count is 2 + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2); + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT execute because total running count (2) meets the concurrency limit (2) + expect(mockExecuteFeature).not.toHaveBeenCalled(); + // Verify it was called WITHOUT autoModeOnly (counts all tasks) + // The coordinator's wrapper passes options through as undefined when not specified + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + undefined + ); + }); + + it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First call: at capacity (2 manual features running) + // Second call: capacity freed (1 feature running) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(2) // at capacity + .mockResolvedValueOnce(1); // capacity available after manual task completes + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + // First iteration: at capacity, should wait + await vi.advanceTimersByTimeAsync(5000); + + // Second iteration: capacity available, should execute + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after capacity freed + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + + it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // Manual tasks already fill the limit + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3); + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Auto mode should remain waiting, not dispatch + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('resumes dispatching when all running tasks complete simultaneously', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First check: all 3 slots occupied + // Second check: all tasks completed simultaneously + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(3) // all slots full + .mockResolvedValueOnce(0); // all tasks completed at once + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + // First iteration: at capacity + await vi.advanceTimersByTimeAsync(5000); + // Second iteration: all freed + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after all tasks freed capacity + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + }); + + describe('priority-based feature selection', () => { + it('selects highest priority feature first (lowest number)', async () => { + const lowPriority: Feature = { + ...testFeature, + id: 'feature-low', + priority: 3, + title: 'Low Priority', + }; + const highPriority: Feature = { + ...testFeature, + id: 'feature-high', + priority: 1, + title: 'High Priority', + }; + const medPriority: Feature = { + ...testFeature, + id: 'feature-med', + priority: 2, + title: 'Med Priority', + }; + + // Return features in non-priority order + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([ + lowPriority, + medPriority, + highPriority, + ]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([lowPriority, medPriority, highPriority]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute the highest priority feature (priority=1) + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true); + }); + + it('uses default priority of 2 when not specified', async () => { + const noPriority: Feature = { ...testFeature, id: 'feature-none', title: 'No Priority' }; + const highPriority: Feature = { + ...testFeature, + id: 'feature-high', + priority: 1, + title: 'High Priority', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noPriority, highPriority]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([noPriority, highPriority]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // High priority (1) should be selected over default priority (2) + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true); + }); + + it('selects first feature when priorities are equal', async () => { + const featureA: Feature = { + ...testFeature, + id: 'feature-a', + priority: 2, + title: 'Feature A', + }; + const featureB: Feature = { + ...testFeature, + id: 'feature-b', + priority: 2, + title: 'Feature B', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([featureA, featureB]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([featureA, featureB]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // When priorities equal, the first feature from the filtered list should be chosen + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-a', true, true); + }); + }); + + describe('dependency-aware feature selection', () => { + it('skips features with unsatisfied dependencies', async () => { + const depFeature: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'in_progress', + title: 'Dependency Feature', + }; + const blockedFeature: Feature = { + ...testFeature, + id: 'feature-blocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Blocked Feature', + }; + const readyFeature: Feature = { + ...testFeature, + id: 'feature-ready', + priority: 2, + title: 'Ready Feature', + }; + + // Pending features (backlog/ready status) + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([blockedFeature, readyFeature]); + // All features (including the in-progress dependency) + vi.mocked(mockLoadAllFeatures).mockResolvedValue([depFeature, blockedFeature, readyFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should skip blocked feature (dependency not complete) and execute ready feature + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-ready', true, true); + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'feature-blocked', + true, + true + ); + }); + + it('picks features whose dependencies are completed', async () => { + const completedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'completed', + title: 'Completed Dependency', + }; + const unblockedFeature: Feature = { + ...testFeature, + id: 'feature-unblocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Unblocked Feature', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedDep, unblockedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute the unblocked feature since its dependency is completed + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked', + true, + true + ); + }); + + it('picks features whose dependencies are verified', async () => { + const verifiedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'verified', + title: 'Verified Dependency', + }; + const unblockedFeature: Feature = { + ...testFeature, + id: 'feature-unblocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Unblocked Feature', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([verifiedDep, unblockedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked', + true, + true + ); + }); + + it('respects both priority and dependencies together', async () => { + const completedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'completed', + title: 'Completed Dep', + }; + const blockedHighPriority: Feature = { + ...testFeature, + id: 'feature-blocked-hp', + dependencies: ['feature-not-done'], + priority: 1, + title: 'Blocked High Priority', + }; + const unblockedLowPriority: Feature = { + ...testFeature, + id: 'feature-unblocked-lp', + dependencies: ['feature-dep'], + priority: 3, + title: 'Unblocked Low Priority', + }; + const unblockedMedPriority: Feature = { + ...testFeature, + id: 'feature-unblocked-mp', + priority: 2, + title: 'Unblocked Med Priority', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([ + blockedHighPriority, + unblockedLowPriority, + unblockedMedPriority, + ]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([ + completedDep, + blockedHighPriority, + unblockedLowPriority, + unblockedMedPriority, + ]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should skip blocked high-priority and pick the unblocked medium-priority + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked-mp', + true, + true + ); + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'feature-blocked-hp', + true, + true + ); + }); + + it('handles features with no dependencies (always eligible)', async () => { + const noDeps: Feature = { + ...testFeature, + id: 'feature-no-deps', + priority: 2, + title: 'No Dependencies', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noDeps]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([noDeps]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-no-deps', + true, + true + ); + }); + }); + + describe('failure tracking', () => { + it('trackFailureAndCheckPauseForProject returns true after threshold', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Track 3 failures (threshold) + const result1 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 1', + }); + expect(result1).toBe(false); + + const result2 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 2', + }); + expect(result2).toBe(false); + + const result3 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 3', + }); + expect(result3).toBe(true); // Should pause after 3 + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('agent errors count as failures', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Agent failed', + }); + + // First error should not pause + expect(result).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('clears failures on success (recordSuccessForProject)', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Add 2 failures + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 1', + }); + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 2', + }); + + // Record success - should clear failures + coordinator.recordSuccessForProject('/test/project'); + + // Next failure should return false (not hitting threshold) + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 3', + }); + expect(result).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('signalShouldPauseForProject emits event and stops loop', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + coordinator.signalShouldPauseForProject('/test/project', { + type: 'quota_exhausted', + message: 'Rate limited', + }); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_paused_failures', + expect.objectContaining({ + errorType: 'quota_exhausted', + projectPath: '/test/project', + }) + ); + + // Loop should be stopped + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('quota/rate limit errors pause immediately', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'quota_exhausted', + message: 'API quota exceeded', + }); + + expect(result).toBe(true); // Should pause immediately + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('rate_limit type also pauses immediately', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'rate_limit', + message: 'Rate limited', + }); + + expect(result).toBe(true); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + }); + + describe('multiple projects', () => { + it('runs concurrent loops for different projects', async () => { + await coordinator.startAutoLoopForProject('/project-a', null, 1); + await coordinator.startAutoLoopForProject('/project-b', null, 1); + + expect(coordinator.isAutoLoopRunningForProject('/project-a', null)).toBe(true); + expect(coordinator.isAutoLoopRunningForProject('/project-b', null)).toBe(true); + + await coordinator.stopAutoLoopForProject('/project-a', null); + await coordinator.stopAutoLoopForProject('/project-b', null); + }); + + it('runs concurrent loops for different worktrees of same project', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await coordinator.startAutoLoopForProject('/test/project', 'feature-branch', 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(true); + expect(coordinator.isAutoLoopRunningForProject('/test/project', 'feature-branch')).toBe(true); + + await coordinator.stopAutoLoopForProject('/test/project', null); + await coordinator.stopAutoLoopForProject('/test/project', 'feature-branch'); + }); + + it('stopping one loop does not affect others', async () => { + await coordinator.startAutoLoopForProject('/project-a', null, 1); + await coordinator.startAutoLoopForProject('/project-b', null, 1); + + await coordinator.stopAutoLoopForProject('/project-a', null); + + expect(coordinator.isAutoLoopRunningForProject('/project-a', null)).toBe(false); + expect(coordinator.isAutoLoopRunningForProject('/project-b', null)).toBe(true); + + await coordinator.stopAutoLoopForProject('/project-b', null); + }); + }); + + describe('getAutoLoopConfigForProject', () => { + it('returns config when loop is running', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 5); + + const config = coordinator.getAutoLoopConfigForProject('/test/project', null); + + expect(config).toEqual({ + maxConcurrency: 5, + useWorktrees: true, + projectPath: '/test/project', + branchName: null, + }); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('returns null when no loop running', () => { + const config = coordinator.getAutoLoopConfigForProject('/test/project', null); + + expect(config).toBeNull(); + }); + }); + + describe('getRunningCountForWorktree', () => { + it('delegates to ConcurrencyManager', async () => { + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3); + + const count = await coordinator.getRunningCountForWorktree('/test/project', null); + + expect(count).toBe(3); + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + undefined + ); + }); + + it('passes autoModeOnly option to ConcurrencyManager', async () => { + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1); + + const count = await coordinator.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + + expect(count).toBe(1); + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + { autoModeOnly: true } + ); + }); + }); + + describe('resetFailureTrackingForProject', () => { + it('clears consecutive failures and paused flag', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Add failures + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + + // Reset failure tracking + coordinator.resetFailureTrackingForProject('/test/project'); + + // Next 3 failures should be needed to trigger pause again + const result1 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + expect(result1).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + }); + + describe('edge cases', () => { + it('handles null settingsService gracefully', async () => { + const coordWithoutSettings = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + null, // No settings service + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + ); + + // Should use default concurrency + const result = await coordWithoutSettings.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(1); // DEFAULT_MAX_CONCURRENCY + + await coordWithoutSettings.stopAutoLoopForProject('/test/project', null); + }); + + it('handles resetStuckFeatures error gracefully', async () => { + vi.mocked(mockResetStuckFeatures).mockRejectedValue(new Error('Reset failed')); + + // Should not throw + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(mockResetStuckFeatures).toHaveBeenCalled(); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('trackFailureAndCheckPauseForProject returns false when no loop', () => { + const result = coordinator.trackFailureAndCheckPauseForProject('/nonexistent', { + type: 'agent_error', + message: 'Error', + }); + + expect(result).toBe(false); + }); + + it('signalShouldPauseForProject does nothing when no loop', () => { + // Should not throw + coordinator.signalShouldPauseForProject('/nonexistent', { + type: 'quota_exhausted', + message: 'Error', + }); + + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_paused_failures', + expect.anything() + ); + }); + + it('does not emit stopped event when loop was not running', async () => { + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_stopped', + expect.anything() + ); + }); + + it('bypasses dependency checks when loadAllFeaturesFn is omitted', async () => { + // Create a dependency feature that is NOT completed (in_progress) + const inProgressDep: Feature = { + ...testFeature, + id: 'dep-feature', + status: 'in_progress', + title: 'In-Progress Dependency', + }; + // Create a pending feature that depends on the in-progress dep + const pendingFeatureWithDep: Feature = { + ...testFeature, + id: 'feature-with-dep', + dependencies: ['dep-feature'], + status: 'ready', + title: 'Feature With Dependency', + }; + + // loadAllFeaturesFn is NOT provided, so dependency checks are bypassed entirely + // (the coordinator returns true instead of calling areDependenciesSatisfied) + const coordWithoutLoadAll = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + // loadAllFeaturesFn omitted + ); + + // pendingFeatures includes the in-progress dep and the pending feature; + // since loadAllFeaturesFn is absent, dependency checks are bypassed, + // so pendingFeatureWithDep is eligible even though its dependency is not completed + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([inProgressDep, pendingFeatureWithDep]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + // The in-progress dep is not finished and not running, so both features pass the + // isFeatureFinished filter; but only pendingFeatureWithDep should be executed + // because we mark dep-feature as running to prevent it from being picked + vi.mocked(mockIsFeatureFinished).mockReturnValue(false); + vi.mocked(mockIsFeatureRunning as ReturnType).mockImplementation( + (id: string) => id === 'dep-feature' + ); + + await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null); + + // pendingFeatureWithDep executes despite its dependency not being completed, + // because dependency checks are bypassed when loadAllFeaturesFn is omitted + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-with-dep', + true, + true + ); + // dep-feature is not executed because it is marked as running + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'dep-feature', + true, + true + ); + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts deleted file mode 100644 index 7c3f908a..00000000 --- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; - -describe('auto-mode-service.ts - Planning Mode', () => { - let service: AutoModeService; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - }); - - afterEach(async () => { - // Clean up any running processes - await service.stopAutoLoop().catch(() => {}); - }); - - describe('getPlanningPromptPrefix', () => { - // Access private method through any cast for testing - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - - it('should return empty string for skip mode', async () => { - const feature = { id: 'test', planningMode: 'skip' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toBe(''); - }); - - it('should return empty string when planningMode is undefined', async () => { - const feature = { id: 'test' }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toBe(''); - }); - - it('should return lite prompt for lite mode without approval', async () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: false, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Planning Phase (Lite Mode)'); - expect(result).toContain('[PLAN_GENERATED]'); - expect(result).toContain('Feature Request'); - }); - - it('should return lite_with_approval prompt for lite mode with approval', async () => { - const feature = { - id: 'test', - planningMode: 'lite' as const, - requirePlanApproval: true, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Planning Phase (Lite Mode)'); - expect(result).toContain('[SPEC_GENERATED]'); - expect(result).toContain( - 'DO NOT proceed with implementation until you receive explicit approval' - ); - }); - - it('should return spec prompt for spec mode', async () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Specification Phase (Spec Mode)'); - expect(result).toContain('```tasks'); - expect(result).toContain('T001'); - expect(result).toContain('[TASK_START]'); - expect(result).toContain('[TASK_COMPLETE]'); - }); - - it('should return full prompt for full mode', async () => { - const feature = { - id: 'test', - planningMode: 'full' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('## Full Specification Phase (Full SDD Mode)'); - expect(result).toContain('Phase 1: Foundation'); - expect(result).toContain('Phase 2: Core Implementation'); - expect(result).toContain('Phase 3: Integration & Testing'); - }); - - it('should include the separator and Feature Request header', async () => { - const feature = { - id: 'test', - planningMode: 'spec' as const, - }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('---'); - expect(result).toContain('## Feature Request'); - }); - - it('should instruct agent to NOT output exploration text', async () => { - const modes = ['lite', 'spec', 'full'] as const; - for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = await getPlanningPromptPrefix(service, feature); - // All modes should have the IMPORTANT instruction about not outputting exploration text - expect(result).toContain('IMPORTANT: Do NOT output exploration text'); - expect(result).toContain('Silently analyze the codebase first'); - } - }); - }); - - describe('parseTasksFromSpec (via module)', () => { - // We need to test the module-level function - // Import it directly for testing - it('should parse tasks from a valid tasks block', async () => { - // This tests the internal logic through integration - // The function is module-level, so we verify behavior through the service - const specContent = ` -## Specification - -\`\`\`tasks -- [ ] T001: Create user model | File: src/models/user.ts -- [ ] T002: Add API endpoint | File: src/routes/users.ts -- [ ] T003: Write unit tests | File: tests/user.test.ts -\`\`\` -`; - // Since parseTasksFromSpec is a module-level function, - // we verify its behavior indirectly through plan parsing - expect(specContent).toContain('T001'); - expect(specContent).toContain('T002'); - expect(specContent).toContain('T003'); - }); - - it('should handle tasks block with phases', () => { - const specContent = ` -\`\`\`tasks -## Phase 1: Setup -- [ ] T001: Initialize project | File: package.json -- [ ] T002: Configure TypeScript | File: tsconfig.json - -## Phase 2: Implementation -- [ ] T003: Create main module | File: src/index.ts -\`\`\` -`; - expect(specContent).toContain('Phase 1'); - expect(specContent).toContain('Phase 2'); - expect(specContent).toContain('T001'); - expect(specContent).toContain('T003'); - }); - }); - - describe('plan approval flow', () => { - it('should track pending approvals correctly', () => { - expect(service.hasPendingApproval('test-feature')).toBe(false); - }); - - it('should allow cancelling non-existent approval without error', () => { - expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); - }); - - it('should return running features count after stop', async () => { - const count = await service.stopAutoLoop(); - expect(count).toBe(0); - }); - }); - - describe('resolvePlanApproval', () => { - it('should return error when no pending approval exists', async () => { - const result = await service.resolvePlanApproval( - 'non-existent-feature', - true, - undefined, - undefined, - undefined - ); - expect(result.success).toBe(false); - expect(result.error).toContain('No pending approval'); - }); - - it('should handle approval with edited plan', async () => { - // Without a pending approval, this should fail gracefully - const result = await service.resolvePlanApproval( - 'test-feature', - true, - 'Edited plan content', - undefined, - undefined - ); - expect(result.success).toBe(false); - }); - - it('should handle rejection with feedback', async () => { - const result = await service.resolvePlanApproval( - 'test-feature', - false, - undefined, - 'Please add more details', - undefined - ); - expect(result.success).toBe(false); - }); - }); - - describe('buildFeaturePrompt', () => { - const defaultTaskExecutionPrompts = { - implementationInstructions: 'Test implementation instructions', - playwrightVerificationInstructions: 'Test playwright instructions', - }; - - const buildFeaturePrompt = ( - svc: any, - feature: any, - taskExecutionPrompts = defaultTaskExecutionPrompts - ) => { - return svc.buildFeaturePrompt(feature, taskExecutionPrompts); - }; - - it('should include feature ID and description', () => { - const feature = { - id: 'feat-123', - description: 'Add user authentication', - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('feat-123'); - expect(result).toContain('Add user authentication'); - }); - - it('should include specification when present', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - spec: 'Detailed specification here', - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('Specification:'); - expect(result).toContain('Detailed specification here'); - }); - - it('should include image paths when present', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - imagePaths: [ - { path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' }, - '/tmp/image2.jpg', - ], - }; - const result = buildFeaturePrompt(service, feature); - expect(result).toContain('Context Images Attached'); - expect(result).toContain('image1.png'); - expect(result).toContain('/tmp/image2.jpg'); - }); - - it('should include implementation instructions', () => { - const feature = { - id: 'feat-123', - description: 'Test feature', - }; - const result = buildFeaturePrompt(service, feature); - // The prompt should include the implementation instructions passed to it - expect(result).toContain('Test implementation instructions'); - expect(result).toContain('Test playwright instructions'); - }); - }); - - describe('extractTitleFromDescription', () => { - const extractTitle = (svc: any, description: string) => { - return svc.extractTitleFromDescription(description); - }; - - it("should return 'Untitled Feature' for empty description", () => { - expect(extractTitle(service, '')).toBe('Untitled Feature'); - expect(extractTitle(service, ' ')).toBe('Untitled Feature'); - }); - - it('should return first line if under 60 characters', () => { - const description = 'Add user login\nWith email validation'; - expect(extractTitle(service, description)).toBe('Add user login'); - }); - - it('should truncate long first lines to 60 characters', () => { - const description = - 'This is a very long feature description that exceeds the sixty character limit significantly'; - const result = extractTitle(service, description); - expect(result.length).toBe(60); - expect(result).toContain('...'); - }); - }); - - describe('PLANNING_PROMPTS structure', () => { - const getPlanningPromptPrefix = (svc: any, feature: any) => { - return svc.getPlanningPromptPrefix(feature); - }; - - it('should have all required planning modes', async () => { - const modes = ['lite', 'spec', 'full'] as const; - for (const mode of modes) { - const feature = { id: 'test', planningMode: mode }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result.length).toBeGreaterThan(100); - } - }); - - it('lite prompt should include correct structure', async () => { - const feature = { id: 'test', planningMode: 'lite' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Goal'); - expect(result).toContain('Approach'); - expect(result).toContain('Files to Touch'); - expect(result).toContain('Tasks'); - expect(result).toContain('Risks'); - }); - - it('spec prompt should include task format instructions', async () => { - const feature = { id: 'test', planningMode: 'spec' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('Problem'); - expect(result).toContain('Solution'); - expect(result).toContain('Acceptance Criteria'); - expect(result).toContain('GIVEN-WHEN-THEN'); - expect(result).toContain('Implementation Tasks'); - expect(result).toContain('Verification'); - }); - - it('full prompt should include phases', async () => { - const feature = { id: 'test', planningMode: 'full' as const }; - const result = await getPlanningPromptPrefix(service, feature); - expect(result).toContain('1. **Problem Statement**'); - expect(result).toContain('2. **User Story**'); - expect(result).toContain('4. **Technical Context**'); - expect(result).toContain('5. **Non-Goals**'); - expect(result).toContain('Phase 1'); - expect(result).toContain('Phase 2'); - expect(result).toContain('Phase 3'); - }); - }); - - describe('status management', () => { - it('should report correct status', () => { - const status = service.getStatus(); - expect(status.runningFeatures).toEqual([]); - expect(status.isRunning).toBe(false); - expect(status.runningCount).toBe(0); - }); - }); -}); diff --git a/apps/server/tests/unit/services/auto-mode-service.test.ts b/apps/server/tests/unit/services/auto-mode-service.test.ts deleted file mode 100644 index a8489033..00000000 --- a/apps/server/tests/unit/services/auto-mode-service.test.ts +++ /dev/null @@ -1,920 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AutoModeService } from '@/services/auto-mode-service.js'; -import type { Feature } from '@automaker/types'; -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -describe('auto-mode-service.ts', () => { - let service: AutoModeService; - const mockEvents = { - subscribe: vi.fn(), - emit: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - service = new AutoModeService(mockEvents as any); - }); - - describe('constructor', () => { - it('should initialize with event emitter', () => { - expect(service).toBeDefined(); - }); - }); - - describe('startAutoLoop', () => { - it('should throw if auto mode is already running', async () => { - // Start first loop - const promise1 = service.startAutoLoop('/test/project', 3); - - // Try to start second loop - await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running'); - - // Cleanup - await service.stopAutoLoop(); - await promise1.catch(() => {}); - }); - - it('should emit auto mode start event', async () => { - const promise = service.startAutoLoop('/test/project', 3); - - // Give it time to emit the event - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(mockEvents.emit).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - message: expect.stringContaining('Auto mode started'), - }) - ); - - // Cleanup - await service.stopAutoLoop(); - await promise.catch(() => {}); - }); - }); - - describe('stopAutoLoop', () => { - it('should stop the auto loop', async () => { - const promise = service.startAutoLoop('/test/project', 3); - - const runningCount = await service.stopAutoLoop(); - - expect(runningCount).toBe(0); - await promise.catch(() => {}); - }); - - it('should return 0 when not running', async () => { - const runningCount = await service.stopAutoLoop(); - expect(runningCount).toBe(0); - }); - }); - - describe('getRunningAgents', () => { - // Helper to access private runningFeatures Map - const getRunningFeaturesMap = (svc: AutoModeService) => - (svc as any).runningFeatures as Map< - string, - { featureId: string; projectPath: string; isAutoMode: boolean } - >; - - // Helper to get the featureLoader and mock its get method - const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).featureLoader = { get: mockFn }; - }; - - it('should return empty array when no agents are running', async () => { - const result = await service.getRunningAgents(); - - expect(result).toEqual([]); - }); - - it('should return running agents with basic info when feature data is not available', async () => { - // Arrange: Add a running feature to the Map - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-123', { - featureId: 'feature-123', - projectPath: '/test/project/path', - isAutoMode: true, - }); - - // Mock featureLoader.get to return null (feature not found) - const getMock = vi.fn().mockResolvedValue(null); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-123', - projectPath: '/test/project/path', - projectName: 'path', - isAutoMode: true, - title: undefined, - description: undefined, - }); - }); - - it('should return running agents with title and description when feature data is available', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-456', { - featureId: 'feature-456', - projectPath: '/home/user/my-project', - isAutoMode: false, - }); - - const mockFeature: Partial = { - id: 'feature-456', - title: 'Implement user authentication', - description: 'Add login and signup functionality', - category: 'auth', - }; - - const getMock = vi.fn().mockResolvedValue(mockFeature); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-456', - projectPath: '/home/user/my-project', - projectName: 'my-project', - isAutoMode: false, - title: 'Implement user authentication', - description: 'Add login and signup functionality', - }); - expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456'); - }); - - it('should handle multiple running agents', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - runningFeaturesMap.set('feature-2', { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - const getMock = vi - .fn() - .mockResolvedValueOnce({ - id: 'feature-1', - title: 'Feature One', - description: 'Description one', - }) - .mockResolvedValueOnce({ - id: 'feature-2', - title: 'Feature Two', - description: 'Description two', - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(2); - expect(getMock).toHaveBeenCalledTimes(2); - }); - - it('should silently handle errors when fetching feature data', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-error', { - featureId: 'feature-error', - projectPath: '/project-error', - isAutoMode: true, - }); - - const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed')); - mockFeatureLoaderGet(service, getMock); - - // Act - should not throw - const result = await service.getRunningAgents(); - - // Assert - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - featureId: 'feature-error', - projectPath: '/project-error', - projectName: 'project-error', - isAutoMode: true, - title: undefined, - description: undefined, - }); - }); - - it('should handle feature with title but no description', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-title-only', { - featureId: 'feature-title-only', - projectPath: '/project', - isAutoMode: false, - }); - - const getMock = vi.fn().mockResolvedValue({ - id: 'feature-title-only', - title: 'Only Title', - // description is undefined - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].title).toBe('Only Title'); - expect(result[0].description).toBeUndefined(); - }); - - it('should handle feature with description but no title', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-desc-only', { - featureId: 'feature-desc-only', - projectPath: '/project', - isAutoMode: false, - }); - - const getMock = vi.fn().mockResolvedValue({ - id: 'feature-desc-only', - description: 'Only description, no title', - // title is undefined - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].title).toBeUndefined(); - expect(result[0].description).toBe('Only description, no title'); - }); - - it('should extract projectName from nested paths correctly', async () => { - // Arrange - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-nested', { - featureId: 'feature-nested', - projectPath: '/home/user/workspace/projects/my-awesome-project', - isAutoMode: true, - }); - - const getMock = vi.fn().mockResolvedValue(null); - mockFeatureLoaderGet(service, getMock); - - // Act - const result = await service.getRunningAgents(); - - // Assert - expect(result[0].projectName).toBe('my-awesome-project'); - }); - - it('should fetch feature data in parallel for multiple agents', async () => { - // Arrange: Add multiple running features - const runningFeaturesMap = getRunningFeaturesMap(service); - for (let i = 1; i <= 5; i++) { - runningFeaturesMap.set(`feature-${i}`, { - featureId: `feature-${i}`, - projectPath: `/project-${i}`, - isAutoMode: i % 2 === 0, - }); - } - - // Track call order - const callOrder: string[] = []; - const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => { - callOrder.push(featureId); - // Simulate async delay to verify parallel execution - await new Promise((resolve) => setTimeout(resolve, 10)); - return { id: featureId, title: `Title for ${featureId}` }; - }); - mockFeatureLoaderGet(service, getMock); - - // Act - const startTime = Date.now(); - const result = await service.getRunningAgents(); - const duration = Date.now() - startTime; - - // Assert - expect(result).toHaveLength(5); - expect(getMock).toHaveBeenCalledTimes(5); - // If executed in parallel, total time should be ~10ms (one batch) - // If sequential, it would be ~50ms (5 * 10ms) - // Allow some buffer for execution overhead - expect(duration).toBeLessThan(40); - }); - }); - - describe('detectOrphanedFeatures', () => { - // Helper to mock featureLoader.getAll - const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).featureLoader = { getAll: mockFn }; - }; - - // Helper to mock getExistingBranches - const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => { - (svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches)); - }; - - it('should return empty array when no features have branch names', async () => { - const getAllMock = vi.fn().mockResolvedValue([ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' }, - { id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' }, - ] satisfies Feature[]); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'develop']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should return empty array when all feature branches exist', async () => { - const getAllMock = vi.fn().mockResolvedValue([ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'feature-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'feature-2', - }, - ] satisfies Feature[]); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should detect orphaned features with missing branches', async () => { - const features: Feature[] = [ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'feature-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'deleted-branch', - }, - { id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toHaveLength(1); - expect(result[0].feature.id).toBe('f2'); - expect(result[0].missingBranch).toBe('deleted-branch'); - }); - - it('should detect multiple orphaned features', async () => { - const features: Feature[] = [ - { - id: 'f1', - title: 'Feature 1', - description: 'desc', - category: 'test', - branchName: 'orphan-1', - }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'orphan-2', - }, - { - id: 'f3', - title: 'Feature 3', - description: 'desc', - category: 'test', - branchName: 'valid-branch', - }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'valid-branch']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toHaveLength(2); - expect(result.map((r) => r.feature.id)).toContain('f1'); - expect(result.map((r) => r.feature.id)).toContain('f2'); - }); - - it('should return empty array when getAll throws error', async () => { - const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features')); - mockFeatureLoaderGetAll(service, getAllMock); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should ignore empty branchName strings', async () => { - const features: Feature[] = [ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' }, - { id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main']); - - const result = await service.detectOrphanedFeatures('/test/project'); - - expect(result).toEqual([]); - }); - - it('should skip features whose branchName matches the primary branch', async () => { - const features: Feature[] = [ - { id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' }, - { - id: 'f2', - title: 'Feature 2', - description: 'desc', - category: 'test', - branchName: 'orphaned', - }, - ]; - const getAllMock = vi.fn().mockResolvedValue(features); - mockFeatureLoaderGetAll(service, getAllMock); - mockGetExistingBranches(service, ['main', 'develop']); - // Mock getCurrentBranch to return 'main' - (service as any).getCurrentBranch = vi.fn().mockResolvedValue('main'); - - const result = await service.detectOrphanedFeatures('/test/project'); - - // Only f2 should be orphaned (orphaned branch doesn't exist) - expect(result).toHaveLength(1); - expect(result[0].feature.id).toBe('f2'); - }); - }); - - describe('markFeatureInterrupted', () => { - // Helper to mock updateFeatureStatus - const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).updateFeatureStatus = mockFn; - }; - - // Helper to mock loadFeature - const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).loadFeature = mockFn; - }; - - it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); - }); - - it('should call updateFeatureStatus with reason when provided', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown'); - - expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); - }); - - it('should propagate errors from updateFeatureStatus', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' }); - const updateMock = vi.fn().mockRejectedValue(new Error('Update failed')); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow( - 'Update failed' - ); - }); - - it('should preserve pipeline_implementation status instead of marking as interrupted', async () => { - const loadMock = vi - .fn() - .mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown'); - - // updateFeatureStatus should NOT be called for pipeline statuses - expect(updateMock).not.toHaveBeenCalled(); - }); - - it('should preserve pipeline_testing status instead of marking as interrupted', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(updateMock).not.toHaveBeenCalled(); - }); - - it('should preserve pipeline_review status instead of marking as interrupted', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(updateMock).not.toHaveBeenCalled(); - }); - - it('should mark feature as interrupted when loadFeature returns null', async () => { - const loadMock = vi.fn().mockResolvedValue(null); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); - }); - - it('should mark feature as interrupted for pending status', async () => { - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markFeatureInterrupted('/test/project', 'feature-123'); - - expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); - }); - }); - - describe('markAllRunningFeaturesInterrupted', () => { - // Helper to access private runningFeatures Map - const getRunningFeaturesMap = (svc: AutoModeService) => - (svc as any).runningFeatures as Map< - string, - { featureId: string; projectPath: string; isAutoMode: boolean } - >; - - // Helper to mock updateFeatureStatus - const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).updateFeatureStatus = mockFn; - }; - - // Helper to mock loadFeature - const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType) => { - (svc as any).loadFeature = mockFn; - }; - - it('should do nothing when no features are running', async () => { - const updateMock = vi.fn().mockResolvedValue(undefined); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(updateMock).not.toHaveBeenCalled(); - }); - - it('should mark a single running feature as interrupted', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); - }); - - it('should mark multiple running features as interrupted', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - runningFeaturesMap.set('feature-2', { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - runningFeaturesMap.set('feature-3', { - featureId: 'feature-3', - projectPath: '/project-a', - isAutoMode: true, - }); - - const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(updateMock).toHaveBeenCalledTimes(3); - expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted'); - expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted'); - expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted'); - }); - - it('should mark features in parallel', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - for (let i = 1; i <= 5; i++) { - runningFeaturesMap.set(`feature-${i}`, { - featureId: `feature-${i}`, - projectPath: `/project-${i}`, - isAutoMode: true, - }); - } - - const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' }); - const callOrder: string[] = []; - const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => { - callOrder.push(featureId); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - const startTime = Date.now(); - await service.markAllRunningFeaturesInterrupted(); - const duration = Date.now() - startTime; - - expect(updateMock).toHaveBeenCalledTimes(5); - // If executed in parallel, total time should be ~10ms - // If sequential, it would be ~50ms (5 * 10ms) - expect(duration).toBeLessThan(40); - }); - - it('should continue marking other features when one fails', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - runningFeaturesMap.set('feature-2', { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' }); - const updateMock = vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('Failed to update')); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - // Should not throw even though one feature failed - await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow(); - - expect(updateMock).toHaveBeenCalledTimes(2); - }); - - it('should use provided reason in logging', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted('manual stop'); - - expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); - }); - - it('should use default reason when none provided', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project/path', - isAutoMode: true, - }); - - const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted(); - - expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); - }); - - it('should preserve pipeline statuses for running features', async () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - runningFeaturesMap.set('feature-2', { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - runningFeaturesMap.set('feature-3', { - featureId: 'feature-3', - projectPath: '/project-c', - isAutoMode: true, - }); - - // feature-1 has in_progress (should be interrupted) - // feature-2 has pipeline_testing (should be preserved) - // feature-3 has pipeline_implementation (should be preserved) - const loadMock = vi - .fn() - .mockImplementation(async (_projectPath: string, featureId: string) => { - if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' }; - if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' }; - if (featureId === 'feature-3') - return { id: 'feature-3', status: 'pipeline_implementation' }; - return null; - }); - const updateMock = vi.fn().mockResolvedValue(undefined); - mockLoadFeature(service, loadMock); - mockUpdateFeatureStatus(service, updateMock); - - await service.markAllRunningFeaturesInterrupted(); - - // Only feature-1 should be marked as interrupted - expect(updateMock).toHaveBeenCalledTimes(1); - expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted'); - }); - }); - - describe('isFeatureRunning', () => { - // Helper to access private runningFeatures Map - const getRunningFeaturesMap = (svc: AutoModeService) => - (svc as any).runningFeatures as Map< - string, - { featureId: string; projectPath: string; isAutoMode: boolean } - >; - - it('should return false when no features are running', () => { - expect(service.isFeatureRunning('feature-123')).toBe(false); - }); - - it('should return true when the feature is running', () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-123', { - featureId: 'feature-123', - projectPath: '/project/path', - isAutoMode: true, - }); - - expect(service.isFeatureRunning('feature-123')).toBe(true); - }); - - it('should return false for non-running feature when others are running', () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-other', { - featureId: 'feature-other', - projectPath: '/project/path', - isAutoMode: true, - }); - - expect(service.isFeatureRunning('feature-123')).toBe(false); - }); - - it('should correctly track multiple running features', () => { - const runningFeaturesMap = getRunningFeaturesMap(service); - runningFeaturesMap.set('feature-1', { - featureId: 'feature-1', - projectPath: '/project-a', - isAutoMode: true, - }); - runningFeaturesMap.set('feature-2', { - featureId: 'feature-2', - projectPath: '/project-b', - isAutoMode: false, - }); - - expect(service.isFeatureRunning('feature-1')).toBe(true); - expect(service.isFeatureRunning('feature-2')).toBe(true); - expect(service.isFeatureRunning('feature-3')).toBe(false); - }); - }); - - describe('interrupted recovery', () => { - async function createFeatureFixture( - projectPath: string, - feature: Partial & Pick - ): Promise { - const featureDir = path.join(projectPath, '.automaker', 'features', feature.id); - await fs.mkdir(featureDir, { recursive: true }); - await fs.writeFile( - path.join(featureDir, 'feature.json'), - JSON.stringify( - { - title: 'Feature', - description: 'Feature description', - category: 'implementation', - status: 'backlog', - ...feature, - }, - null, - 2 - ) - ); - return featureDir; - } - - it('should resume features marked as interrupted after restart', async () => { - const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-')); - try { - const featureDir = await createFeatureFixture(projectPath, { - id: 'feature-interrupted', - status: 'interrupted', - }); - await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress'); - await createFeatureFixture(projectPath, { - id: 'feature-complete', - status: 'completed', - }); - - const resumeFeatureMock = vi.fn().mockResolvedValue(undefined); - (service as any).resumeFeature = resumeFeatureMock; - - await (service as any).resumeInterruptedFeatures(projectPath); - - expect(resumeFeatureMock).toHaveBeenCalledTimes(1); - expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true); - } finally { - await fs.rm(projectPath, { recursive: true, force: true }); - } - }); - - it('should include interrupted features in pending recovery candidates', async () => { - const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-')); - try { - await createFeatureFixture(projectPath, { - id: 'feature-interrupted', - status: 'interrupted', - }); - await createFeatureFixture(projectPath, { - id: 'feature-waiting-approval', - status: 'waiting_approval', - }); - - const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null); - const pendingIds = pendingFeatures.map((feature: Feature) => feature.id); - - expect(pendingIds).toContain('feature-interrupted'); - expect(pendingIds).not.toContain('feature-waiting-approval'); - } finally { - await fs.rm(projectPath, { recursive: true, force: true }); - } - }); - }); -}); diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 7901192c..bb88381e 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -177,6 +177,66 @@ describe('claude-usage-service.ts', () => { // BEL is stripped, newlines and tabs preserved expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); }); + + it('should convert cursor forward (ESC[nC) to spaces', () => { + const service = new ClaudeUsageService(); + // Claude CLI TUI uses ESC[1C instead of space between words + const input = 'Current\x1B[1Csession'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session'); + }); + + it('should handle multi-character cursor forward sequences', () => { + const service = new ClaudeUsageService(); + // ESC[3C = move cursor forward 3 positions = 3 spaces + const input = 'Hello\x1B[3Cworld'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Hello world'); + }); + + it('should handle real Claude CLI TUI output with cursor movement codes', () => { + const service = new ClaudeUsageService(); + // Simulates actual Claude CLI /usage output where words are separated by ESC[1C + const input = + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toContain('Current week (all models)'); + expect(result).toContain('51% used'); + expect(result).toContain('Resets Feb 19 at 3pm (America/Los_Angeles)'); + }); + + it('should parse usage output with cursor movement codes between words', () => { + const service = new ClaudeUsageService(); + // Simulates the full /usage TUI output with ESC[1C between every word + const output = + 'Current\x1B[1Csession\n' + + '\x1B[32m█████████████▌\x1B[0m\x1B[1C27%\x1B[1Cused\n' + + 'Resets\x1B[1C9pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(Sonnet\x1B[1Conly)\n' + + '\x1B[32m██▌\x1B[0m\x1B[1C5%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C11pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(27); + expect(result.weeklyPercentage).toBe(51); + expect(result.sonnetWeeklyPercentage).toBe(5); + expect(result.weeklyResetText).toContain('Resets Feb 19 at 3pm'); + expect(result.weeklyResetText).not.toContain('America/Los_Angeles'); + }); }); describe('parseResetTime', () => { diff --git a/apps/server/tests/unit/services/concurrency-manager.test.ts b/apps/server/tests/unit/services/concurrency-manager.test.ts new file mode 100644 index 00000000..c7cce482 --- /dev/null +++ b/apps/server/tests/unit/services/concurrency-manager.test.ts @@ -0,0 +1,693 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + ConcurrencyManager, + type RunningFeature, + type GetCurrentBranchFn, +} from '@/services/concurrency-manager.js'; + +describe('ConcurrencyManager', () => { + let manager: ConcurrencyManager; + let mockGetCurrentBranch: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + // Default: primary branch is 'main' + mockGetCurrentBranch = vi.fn().mockResolvedValue('main'); + manager = new ConcurrencyManager(mockGetCurrentBranch); + }); + + describe('acquire', () => { + it('should create new entry with leaseCount: 1 on first acquire', () => { + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(result.featureId).toBe('feature-1'); + expect(result.projectPath).toBe('/test/project'); + expect(result.isAutoMode).toBe(true); + expect(result.leaseCount).toBe(1); + expect(result.worktreePath).toBeNull(); + expect(result.branchName).toBeNull(); + expect(result.startTime).toBeDefined(); + expect(result.abortController).toBeInstanceOf(AbortController); + }); + + it('should increment leaseCount when allowReuse is true for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire with allowReuse + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(2); + }); + + it('should throw "already running" when allowReuse is false for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire without allowReuse + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }) + ).toThrow('already running'); + }); + + it('should throw "already running" when allowReuse is explicitly false', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: false, + }) + ).toThrow('already running'); + }); + + it('should use provided abortController', () => { + const customAbortController = new AbortController(); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + abortController: customAbortController, + }); + + expect(result.abortController).toBe(customAbortController); + }); + + it('should return the existing entry when allowReuse is true', () => { + const first = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const second = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // Should be the same object reference + expect(second).toBe(first); + }); + + it('should allow multiple nested acquire calls with allowReuse', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(3); + }); + }); + + describe('release', () => { + it('should decrement leaseCount on release', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.release('feature-1'); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.leaseCount).toBe(1); + }); + + it('should delete entry when leaseCount reaches 0', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should delete entry immediately when force is true regardless of leaseCount', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3, but force should still delete + manager.release('feature-1', { force: true }); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should do nothing when releasing non-existent feature', () => { + // Should not throw + manager.release('non-existent-feature'); + manager.release('non-existent-feature', { force: true }); + }); + + it('should only delete entry after all leases are released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3 + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('isRunning', () => { + it('should return false when feature is not running', () => { + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should return true when feature is running', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(manager.isRunning('feature-1')).toBe(true); + }); + + it('should return false after feature is released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('getRunningFeature', () => { + it('should return undefined for non-existent feature', () => { + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should return the RunningFeature entry', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry).toBeDefined(); + expect(entry?.featureId).toBe('feature-1'); + expect(entry?.projectPath).toBe('/test/project'); + }); + }); + + describe('getRunningCount (project-level)', () => { + it('should return 0 when no features are running', () => { + expect(manager.getRunningCount('/test/project')).toBe(0); + }); + + it('should count features for specific project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(2); + }); + + it('should only count features for the specified project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/project-a', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/project-a')).toBe(2); + expect(manager.getRunningCount('/project-b')).toBe(1); + expect(manager.getRunningCount('/project-c')).toBe(0); + }); + }); + + describe('getRunningCountForWorktree', () => { + it('should return 0 when no features are running', async () => { + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(0); + }); + + it('should count features with null branchName as main worktree', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + // entry.branchName is null by default + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features matching primary branch as main worktree', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features with exact branch match for feature worktrees', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-branch' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + // feature-2 has null branchName + + const featureBranchCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(featureBranchCount).toBe(1); + + const mainWorktreeCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainWorktreeCount).toBe(1); + }); + + it('should respect branch normalization (main is treated as null)', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + // Feature with branchName 'main' should count as main worktree + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + // Feature with branchName null should also count as main worktree + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + const mainCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainCount).toBe(2); + }); + + it('should count only auto-mode features when autoModeOnly is true', async () => { + // Auto-mode feature on main worktree + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Manual feature on main worktree + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + + // Without autoModeOnly: counts both + const totalCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(totalCount).toBe(2); + + // With autoModeOnly: counts only auto-mode features + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(1); + }); + + it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => { + // Auto-mode feature on feature branch + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' }); + + // Manual feature on same feature branch + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' }); + + // Another auto-mode feature on different branch (should not be counted) + manager.acquire({ + featureId: 'feature-other', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-other', { branchName: 'other-branch' }); + + const autoModeCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch', + { autoModeOnly: true } + ); + expect(autoModeCount).toBe(1); + + const totalCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(totalCount).toBe(2); + }); + + it('should return 0 when autoModeOnly is true and only manual features are running', async () => { + manager.acquire({ + featureId: 'feature-manual-1', + projectPath: '/test/project', + isAutoMode: false, + }); + + manager.acquire({ + featureId: 'feature-manual-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(0); + }); + + it('should filter by both projectPath and branchName', async () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-x' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { branchName: 'feature-x' }); + + const countA = await manager.getRunningCountForWorktree('/project-a', 'feature-x'); + const countB = await manager.getRunningCountForWorktree('/project-b', 'feature-x'); + + expect(countA).toBe(1); + expect(countB).toBe(1); + }); + }); + + describe('getAllRunning', () => { + it('should return empty array when no features are running', () => { + expect(manager.getAllRunning()).toEqual([]); + }); + + it('should return array with all running features', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: false, + }); + + const running = manager.getAllRunning(); + expect(running).toHaveLength(2); + expect(running.map((r) => r.featureId)).toContain('feature-1'); + expect(running.map((r) => r.featureId)).toContain('feature-2'); + }); + + it('should include feature metadata', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4', provider: 'claude' }); + + const running = manager.getAllRunning(); + expect(running[0].model).toBe('claude-sonnet-4'); + expect(running[0].provider).toBe('claude'); + }); + }); + + describe('updateRunningFeature', () => { + it('should update worktreePath and branchName', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + worktreePath: '/worktrees/feature-1', + branchName: 'feature-1-branch', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.worktreePath).toBe('/worktrees/feature-1'); + expect(entry?.branchName).toBe('feature-1-branch'); + }); + + it('should update model and provider', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + model: 'claude-opus-4-5-20251101', + provider: 'claude', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.model).toBe('claude-opus-4-5-20251101'); + expect(entry?.provider).toBe('claude'); + }); + + it('should do nothing for non-existent feature', () => { + // Should not throw + manager.updateRunningFeature('non-existent', { model: 'test' }); + }); + + it('should preserve other properties when updating partial fields', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const original = manager.getRunningFeature('feature-1'); + const originalStartTime = original?.startTime; + + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4' }); + + const updated = manager.getRunningFeature('feature-1'); + expect(updated?.startTime).toBe(originalStartTime); + expect(updated?.projectPath).toBe('/test/project'); + expect(updated?.isAutoMode).toBe(true); + expect(updated?.model).toBe('claude-sonnet-4'); + }); + }); + + describe('edge cases', () => { + it('should handle multiple features for same project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(3); + expect(manager.isRunning('feature-1')).toBe(true); + expect(manager.isRunning('feature-2')).toBe(true); + expect(manager.isRunning('feature-3')).toBe(true); + }); + + it('should handle features across different worktrees', async () => { + // Main worktree feature + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Worktree A feature + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { + worktreePath: '/worktrees/a', + branchName: 'branch-a', + }); + + // Worktree B feature + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-3', { + worktreePath: '/worktrees/b', + branchName: 'branch-b', + }); + + expect(await manager.getRunningCountForWorktree('/test/project', null)).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-a')).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-b')).toBe(1); + expect(manager.getRunningCount('/test/project')).toBe(3); + }); + + it('should return 0 counts and empty arrays for empty state', () => { + expect(manager.getRunningCount('/any/project')).toBe(0); + expect(manager.getAllRunning()).toEqual([]); + expect(manager.isRunning('any-feature')).toBe(false); + expect(manager.getRunningFeature('any-feature')).toBeUndefined(); + }); + }); +}); diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts index e95259bc..3e316450 100644 --- a/apps/server/tests/unit/services/dev-server-service.test.ts +++ b/apps/server/tests/unit/services/dev-server-service.test.ts @@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => { await service.startDevServer(testDir, testDir); // Simulate HTTPS dev server - mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n')); + mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n')); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => { expect(serverInfo?.url).toBe(firstUrl); expect(serverInfo?.url).toBe('http://localhost:5173/'); }); + + it('should detect Astro format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Astro uses the same "Local:" prefix as Vite + mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n')); + mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern + expect(serverInfo?.url).toBe('http://localhost:4321/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Remix format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Remix App Server started at http://localhost:3000\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Django format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Starting development server at http://127.0.0.1:8000/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://127.0.0.1:8000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Webpack Dev Server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from(' [webpack-dev-server] Project is running at http://localhost:8080/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8080/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect PHP built-in server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Development Server (http://localhost:8000) started\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "listening on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers only print the port number, not a full URL + mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "running on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should strip ANSI escape codes before detecting URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate Vite output with ANSI color codes + mockProcess.stdout.emit( + 'data', + Buffer.from( + ' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:5173/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize 0.0.0.0 to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize [::] to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should update port field when detected URL has different port', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + const allocatedPort = result.result?.port; + + // Server starts on a completely different port (ignoring PORT env var) + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9999/'); + expect(serverInfo?.port).toBe(9999); + // The port should be different from what was initially allocated + if (allocatedPort !== 9999) { + expect(serverInfo?.port).not.toBe(allocatedPort); + } + }); + + it('should detect URL from stderr output', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers output URL info to stderr + mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should not match URLs without a port (non-dev-server URLs)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + // CDN/external URLs should not be detected + mockProcess.stdout.emit( + 'data', + Buffer.from('Downloading from https://cdn.example.com/bundle.js\n') + ); + mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Should keep the initial allocated URL since external URLs don't match + expect(serverInfo?.url).toBe(result.result?.url); + expect(serverInfo?.urlDetected).toBe(false); + }); + + it('should handle URLs with trailing punctuation', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // URL followed by punctuation + mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Express/Fastify format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Angular CLI format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Angular CLI output + mockProcess.stderr.emit( + 'data', + Buffer.from( + '** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4200/'); + expect(serverInfo?.urlDetected).toBe(true); + }); }); }); @@ -531,6 +893,7 @@ function createMockProcess() { mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockProcess.killed = false; + mockProcess.pid = 12345; // Don't exit immediately - let the test control the lifecycle return mockProcess; diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts new file mode 100644 index 00000000..ab06f9c1 --- /dev/null +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -0,0 +1,835 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventHookService } from '../../../src/services/event-hook-service.js'; +import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { EventHistoryService } from '../../../src/services/event-history-service.js'; +import type { FeatureLoader } from '../../../src/services/feature-loader.js'; + +/** + * Create a mock EventEmitter for testing + */ +function createMockEventEmitter(): EventEmitter & { + subscribers: Set; + simulateEvent: (type: EventType, payload: unknown) => void; +} { + const subscribers = new Set(); + + return { + subscribers, + emit(type: EventType, payload: unknown) { + for (const callback of subscribers) { + callback(type, payload); + } + }, + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + simulateEvent(type: EventType, payload: unknown) { + for (const callback of subscribers) { + callback(type, payload); + } + }, + }; +} + +/** + * Create a mock SettingsService + */ +function createMockSettingsService(hooks: unknown[] = []): SettingsService { + return { + getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }), + } as unknown as SettingsService; +} + +/** + * Create a mock EventHistoryService + */ +function createMockEventHistoryService() { + return { + storeEvent: vi.fn().mockResolvedValue({ id: 'test-event-id' }), + } as unknown as EventHistoryService; +} + +/** + * Create a mock FeatureLoader + */ +function createMockFeatureLoader(features: Record = {}) { + return { + get: vi.fn().mockImplementation((_projectPath: string, featureId: string) => { + return Promise.resolve(features[featureId] || null); + }), + } as unknown as FeatureLoader; +} + +describe('EventHookService', () => { + let service: EventHookService; + let mockEmitter: ReturnType; + let mockSettingsService: ReturnType; + let mockEventHistoryService: ReturnType; + let mockFeatureLoader: ReturnType; + + beforeEach(() => { + service = new EventHookService(); + mockEmitter = createMockEventEmitter(); + mockSettingsService = createMockSettingsService(); + mockEventHistoryService = createMockEventHistoryService(); + mockFeatureLoader = createMockFeatureLoader(); + }); + + afterEach(() => { + service.destroy(); + }); + + describe('initialize', () => { + it('should subscribe to the event emitter', () => { + service.initialize(mockEmitter, mockSettingsService, mockEventHistoryService); + expect(mockEmitter.subscribers.size).toBe(1); + }); + + it('should log initialization', () => { + service.initialize(mockEmitter, mockSettingsService); + expect(mockEmitter.subscribers.size).toBe(1); + }); + }); + + describe('destroy', () => { + it('should unsubscribe from the event emitter', () => { + service.initialize(mockEmitter, mockSettingsService); + expect(mockEmitter.subscribers.size).toBe(1); + + service.destroy(); + expect(mockEmitter.subscribers.size).toBe(0); + }); + }); + + describe('event mapping - auto_mode_feature_complete', () => { + it('should map to feature_success when passes is true', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s', + projectPath: '/test/project', + }); + + // Allow async processing + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + }); + + it('should map to feature_error when passes is false', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.passes).toBe(false); + }); + + it('should NOT populate error field for successful feature completion', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s - auto-verified', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + // Critical: error should NOT contain the success message + expect(storeCall.error).toBeUndefined(); + expect(storeCall.errorType).toBeUndefined(); + }); + + it('should populate error field for failed feature completion', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + // Error field should be populated for error triggers + expect(storeCall.error).toBe('Feature stopped by user'); + }); + + it('should ignore feature complete events without explicit auto execution mode', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + featureId: 'feat-1', + featureName: 'Manual Feature', + passes: true, + message: 'Manually verified', + projectPath: '/test/project', + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + }); + + describe('event mapping - feature:completed', () => { + it('should map manual completion to feature_success', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('feature:completed', { + featureId: 'feat-1', + featureName: 'Manual Feature', + projectPath: '/test/project', + passes: true, + executionMode: 'manual', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + }); + }); + + describe('event mapping - auto_mode_error', () => { + it('should map to feature_error when featureId is present', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Network timeout', + errorType: 'network', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.error).toBe('Network timeout'); + expect(storeCall.errorType).toBe('network'); + }); + + it('should map to auto_mode_error when featureId is not present', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + error: 'System error', + errorType: 'system', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('auto_mode_error'); + expect(storeCall.error).toBe('System error'); + expect(storeCall.errorType).toBe('system'); + }); + }); + + describe('event mapping - auto_mode_idle', () => { + it('should map to auto_mode_complete', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_idle', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('auto_mode_complete'); + }); + }); + + describe('event mapping - feature:created', () => { + it('should trigger feature_created hook', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('feature:created', { + featureId: 'feat-1', + featureName: 'New Feature', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_created'); + expect(storeCall.featureId).toBe('feat-1'); + }); + }); + + describe('event mapping - unhandled events', () => { + it('should ignore auto-mode events with unrecognized types', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_progress', + featureId: 'feat-1', + content: 'Working...', + projectPath: '/test/project', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + + it('should ignore events without a type', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + featureId: 'feat-1', + projectPath: '/test/project', + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + }); + + describe('hook execution', () => { + it('should execute matching enabled hooks for feature_success', async () => { + const hooks = [ + { + id: 'hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Success Hook', + action: { + type: 'shell', + command: 'echo "success"', + }, + }, + { + id: 'hook-2', + enabled: true, + trigger: 'feature_error', + name: 'Error Hook', + action: { + type: 'shell', + command: 'echo "error"', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockSettingsService.getGlobalSettings).toHaveBeenCalled(); + }); + + // The error hook should NOT have been triggered for a success event + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + }); + + it('should NOT execute error hooks when feature completes successfully', async () => { + // This is the key regression test for the bug: + // "Error event hook fired when a feature completes successfully" + const hooks = [ + { + id: 'hook-error', + enabled: true, + trigger: 'feature_error', + name: 'Error Notification', + action: { + type: 'shell', + command: 'echo "ERROR FIRED"', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s - auto-verified', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Verify the trigger was feature_success, not feature_error + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + // And no error information should be present + expect(storeCall.error).toBeUndefined(); + expect(storeCall.errorType).toBeUndefined(); + }); + }); + + describe('feature name loading', () => { + it('should load feature name from feature loader when not in payload', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Loaded Feature Title' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.featureName).toBe('Loaded Feature Title'); + }); + + it('should fall back to payload featureName when loader fails', async () => { + mockFeatureLoader = createMockFeatureLoader({}); // Empty - no features found + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Fallback Name', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.featureName).toBe('Fallback Name'); + }); + }); + + describe('event mapping - feature_status_changed (non-auto-mode completion)', () => { + it('should trigger feature_success when status changes to verified', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.featureName).toBe('Manual Feature'); + expect(storeCall.passes).toBe(true); + }); + + it('should trigger feature_success when status changes to waiting_approval', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'waiting_approval', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + expect(storeCall.featureName).toBe('Manual Feature'); + }); + + it('should NOT trigger hooks for non-completion status changes', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'in_progress', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + + it('should NOT double-fire hooks when auto_mode_feature_complete already fired', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_feature_complete fires (auto-mode path) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Auto Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once (from auto_mode_feature_complete) + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should NOT double-fire hooks when auto_mode_error already fired for feature', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_error fires for a feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Something failed', + errorType: 'execution', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature (e.g., reset to backlog) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', // unlikely after error, but tests the dedup + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should fire hooks for different features independently', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Auto-mode completion for feat-1 + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Manual completion for feat-2 (different feature) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-2', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(2); + }); + + // feat-2 should have triggered feature_success + const secondCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[1][0]; + expect(secondCall.trigger).toBe('feature_success'); + expect(secondCall.featureId).toBe('feat-2'); + }); + }); + + describe('error context for error events', () => { + it('should use payload.error when available for error triggers', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Authentication failed', + errorType: 'auth', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.error).toBe('Authentication failed'); + expect(storeCall.errorType).toBe('auth'); + }); + + it('should fall back to payload.message for error field in error triggers', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.error).toBe('Feature stopped by user'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts new file mode 100644 index 00000000..7c2f3e0f --- /dev/null +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -0,0 +1,1878 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import type { Feature } from '@automaker/types'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + */ +const normalizePath = (p: string): string => path.resolve(p); +import { + ExecutionService, + type RunAgentFn, + type ExecutePipelineFn, + type UpdateFeatureStatusFn, + type LoadFeatureFn, + type GetPlanningPromptPrefixFn, + type SaveFeatureSummaryFn, + type RecordLearningsFn, + type ContextExistsFn, + type ResumeFeatureFn, + type TrackFailureFn, + type SignalPauseFn, + type RecordSuccessFn, +} from '../../../src/services/execution-service.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { + ConcurrencyManager, + RunningFeature, +} from '../../../src/services/concurrency-manager.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import { pipelineService } from '../../../src/services/pipeline-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; +import { extractSummary } from '../../../src/services/spec-parser.js'; +import { resolveModelString } from '@automaker/model-resolver'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + getPipelineConfig: vi.fn(), + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + }, +})); + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock sdk-options +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +// Mock provider-factory +vi.mock('../../../src/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderNameForModel: vi.fn().mockReturnValue('anthropic'), + }, +})); + +// Mock spec-parser +vi.mock('../../../src/services/spec-parser.js', () => ({ + extractSummary: vi.fn().mockReturnValue('Test summary'), +})); + +// Mock @automaker/utils +vi.mock('@automaker/utils', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + classifyError: vi.fn((error: unknown) => { + const err = error as Error | null; + if (err?.name === 'AbortError' || err?.message?.includes('abort')) { + return { isAbort: true, type: 'abort', message: 'Aborted' }; + } + return { isAbort: false, type: 'unknown', message: err?.message || 'Unknown error' }; + }), + loadContextFiles: vi.fn(), + recordMemoryUsage: vi.fn().mockResolvedValue(undefined), +})); + +describe('execution-service.ts', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockConcurrencyManager: ConcurrencyManager; + let mockWorktreeResolver: WorktreeResolver; + let mockSettingsService: SettingsService | null; + + // Callback mocks + let mockRunAgentFn: RunAgentFn; + let mockExecutePipelineFn: ExecutePipelineFn; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadFeatureFn: LoadFeatureFn; + let mockGetPlanningPromptPrefixFn: GetPlanningPromptPrefixFn; + let mockSaveFeatureSummaryFn: SaveFeatureSummaryFn; + let mockRecordLearningsFn: RecordLearningsFn; + let mockContextExistsFn: ContextExistsFn; + let mockResumeFeatureFn: ResumeFeatureFn; + let mockTrackFailureFn: TrackFailureFn; + let mockSignalPauseFn: SignalPauseFn; + let mockRecordSuccessFn: RecordSuccessFn; + let mockSaveExecutionStateFn: vi.Mock; + let mockLoadContextFilesFn: vi.Mock; + + let service: ExecutionService; + + // Test data + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'backlog', + branchName: 'feature/test-1', + }; + + const createRunningFeature = (featureId: string): RunningFeature => ({ + featureId, + projectPath: '/test/project', + worktreePath: null, + branchName: null, + abortController: new AbortController(), + isAutoMode: false, + startTime: Date.now(), + leaseCount: 1, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + ...createRunningFeature(featureId), + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn(), + isRunning: vi.fn(), + } as unknown as ConcurrencyManager; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + } as unknown as WorktreeResolver; + + mockSettingsService = null; + + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined); + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + mockGetPlanningPromptPrefixFn = vi.fn().mockResolvedValue(''); + mockSaveFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + mockRecordLearningsFn = vi.fn().mockResolvedValue(undefined); + mockContextExistsFn = vi.fn().mockResolvedValue(false); + mockResumeFeatureFn = vi.fn().mockResolvedValue(undefined); + mockTrackFailureFn = vi.fn().mockReturnValue(false); + mockSignalPauseFn = vi.fn(); + mockRecordSuccessFn = vi.fn(); + mockSaveExecutionStateFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ + formattedPrompt: 'test context', + memoryFiles: [], + }); + + // Default mocks for secureFs + // Include tool usage markers to simulate meaningful agent output. + // The execution service checks for '🔧 Tool:' markers and minimum + // output length to determine if the agent did real work. + vi.mocked(secureFs.readFile).mockResolvedValue( + 'Starting implementation...\n\n🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\n\n' + + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts", "old_string": "foo", "new_string": "bar"}\n\n' + + 'Implementation complete. Updated the code as requested.' + ); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Re-setup platform mocks + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + + // Default pipeline config (no steps) + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ version: 1, steps: [] }); + + // Re-setup settings helpers mocks (vi.clearAllMocks clears implementations) + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + } as Awaited>); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(getUseClaudeCodeSystemPromptSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + // Re-setup spec-parser mock + vi.mocked(extractSummary).mockReturnValue('Test summary'); + + // Re-setup model-resolver mock + vi.mocked(resolveModelString).mockReturnValue('claude-sonnet-4'); + + service = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('creates service with all dependencies', () => { + expect(service).toBeInstanceOf(ExecutionService); + }); + + it('accepts null settingsService', () => { + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + null, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + expect(svc).toBeInstanceOf(ExecutionService); + }); + }); + + describe('buildFeaturePrompt', () => { + const taskPrompts = { + implementationInstructions: 'impl instructions', + playwrightVerificationInstructions: 'playwright instructions', + }; + + it('includes feature title and description', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('**Feature ID:** feature-1'); + expect(prompt).toContain('Test description'); + }); + + it('includes specification when present', () => { + const featureWithSpec: Feature = { + ...testFeature, + spec: 'Detailed specification here', + }; + const prompt = service.buildFeaturePrompt(featureWithSpec, taskPrompts); + expect(prompt).toContain('**Specification:**'); + expect(prompt).toContain('Detailed specification here'); + }); + + it('includes acceptance criteria from task prompts', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('impl instructions'); + }); + + it('adds playwright instructions when skipTests is false', () => { + const featureWithTests: Feature = { ...testFeature, skipTests: false }; + const prompt = service.buildFeaturePrompt(featureWithTests, taskPrompts); + expect(prompt).toContain('playwright instructions'); + }); + + it('omits playwright instructions when skipTests is true', () => { + const featureWithoutTests: Feature = { ...testFeature, skipTests: true }; + const prompt = service.buildFeaturePrompt(featureWithoutTests, taskPrompts); + expect(prompt).not.toContain('playwright instructions'); + }); + + it('includes images note when imagePaths present', () => { + const featureWithImages: Feature = { + ...testFeature, + imagePaths: ['/path/to/image.png', { path: '/path/to/image2.jpg', mimeType: 'image/jpeg' }], + }; + const prompt = service.buildFeaturePrompt(featureWithImages, taskPrompts); + expect(prompt).toContain('Context Images Attached:'); + expect(prompt).toContain('2 image(s)'); + }); + + it('extracts title from first line of description', () => { + const featureWithLongDesc: Feature = { + ...testFeature, + description: 'First line title\nRest of description', + }; + const prompt = service.buildFeaturePrompt(featureWithLongDesc, taskPrompts); + expect(prompt).toContain('**Title:** First line title'); + }); + + it('truncates long titles to 60 characters', () => { + const longDescription = 'A'.repeat(100); + const featureWithLongTitle: Feature = { + ...testFeature, + description: longDescription, + }; + const prompt = service.buildFeaturePrompt(featureWithLongTitle, taskPrompts); + expect(prompt).toContain('**Title:** ' + 'A'.repeat(57) + '...'); + }); + }); + + describe('executeFeature', () => { + it('throws if feature not found', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue(null); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'nonexistent'); + + // Error event should be emitted + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ featureId: 'nonexistent' }) + ); + }); + + it('acquires running feature slot', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith( + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + }); + + it('updates status to in_progress before starting', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + }); + + it('emits feature_start event after status update', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_start', + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + + // Verify order: status update happens before event + const statusCallIndex = mockUpdateFeatureStatusFn.mock.invocationCallOrder[0]; + const eventCallIndex = mockEventBus.emitAutoModeEvent.mock.invocationCallOrder[0]; + expect(statusCallIndex).toBeLessThan(eventCallIndex); + }); + + it('runs agent with correct prompt', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project + expect(callArgs[1]).toBe('feature-1'); + expect(callArgs[2]).toContain('Feature Implementation Task'); + expect(callArgs[3]).toBeInstanceOf(AbortController); + expect(callArgs[4]).toBe('/test/project'); + // Model (index 6) should be resolved + expect(callArgs[6]).toBe('claude-sonnet-4'); + }); + + it('executes pipeline after agent completes', async () => { + const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }]; + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: pipelineSteps as any, + }); + + await service.executeFeature('/test/project', 'feature-1'); + + // Agent runs first + expect(mockRunAgentFn).toHaveBeenCalled(); + // Then pipeline executes + expect(mockExecutePipelineFn).toHaveBeenCalledWith( + expect.objectContaining({ + projectPath: '/test/project', + featureId: 'feature-1', + steps: pipelineSteps, + }) + ); + }); + + it('updates status to verified on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('updates status to waiting_approval when skipTests is true', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('records success on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordSuccessFn).toHaveBeenCalled(); + }); + + it('releases running feature in finally block', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + + it('redirects to resumeFeature when context exists', async () => { + mockContextExistsFn = vi.fn().mockResolvedValue(true); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', true); + + expect(mockResumeFeatureFn).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + // Should not run agent + expect(mockRunAgentFn).not.toHaveBeenCalled(); + }); + + it('emits feature_complete event on success when isAutoMode is true', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: true, + }) + ); + }); + + it('does not emit feature_complete event on success when isAutoMode is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false, false); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + }); + + describe('executeFeature - approved plan handling', () => { + it('builds continuation prompt for approved plan', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'The approved plan content' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Agent should be called with continuation prompt + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + expect(callArgs[1]).toBe('feature-1'); + expect(callArgs[2]).toContain('The approved plan content'); + }); + + it('recursively calls executeFeature with continuation', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // acquire should be called twice - once for initial, once for recursive + expect(mockConcurrencyManager.acquire).toHaveBeenCalledTimes(2); + // Second call should have allowReuse: true + expect(mockConcurrencyManager.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ allowReuse: true }) + ); + }); + + it('skips contextExists check when continuation prompt provided', async () => { + // Feature has context AND approved plan, but continuation prompt is provided + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + mockContextExistsFn = vi.fn().mockResolvedValue(true); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // resumeFeature should NOT be called even though context exists + // because we're going through approved plan flow + expect(mockResumeFeatureFn).not.toHaveBeenCalled(); + }); + }); + + describe('executeFeature - incomplete task retry', () => { + const createServiceWithMocks = () => { + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('does not re-run agent when feature has no tasks', async () => { + // Feature with no planSpec/tasks - should complete normally with 1 agent call + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('does not re-run agent when all tasks are completed', async () => { + const featureWithCompletedTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithCompletedTasks); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Only the initial agent call + the approved-plan recursive call + // The approved plan triggers recursive executeFeature, so runAgentFn is called once in the inner call + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent when there are pending tasks after initial execution', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + tasksCompleted: 1, + }, + }; + + // After first agent run, loadFeature returns feature with pending tasks + // After second agent run, loadFeature returns feature with all tasks completed + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'completed', description: 'Third task' }, + ], + tasksCompleted: 3, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + // First call: initial feature load at the top of executeFeature + // Second call: after first agent run (check for incomplete tasks) - has pending tasks + // Third call: after second agent run (check for incomplete tasks) - all done + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have called runAgentFn twice: initial + one retry + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry call should contain continuation prompt about incomplete tasks + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('Continue Implementation - Incomplete Tasks'); + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('T003'); + + // Should have emitted a progress event about retrying + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_progress', + expect.objectContaining({ + featureId: 'feature-1', + content: expect.stringContaining('Re-running to complete tasks'), + }) + ); + }); + + it('respects maximum retry attempts', async () => { + const featureAlwaysPending: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + // Always return feature with pending tasks (agent never completes T002) + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureAlwaysPending); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Initial run + 3 retry attempts = 4 total + expect(mockRunAgentFn).toHaveBeenCalledTimes(4); + + // Should still set final status even with incomplete tasks + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('stops retrying when abort signal is triggered', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPendingTasks); + + // Simulate abort after first agent run + let runCount = 0; + const capturedAbortController = { current: null as AbortController | null }; + mockRunAgentFn = vi.fn().mockImplementation((_wd, _fid, _prompt, abortCtrl) => { + capturedAbortController.current = abortCtrl; + runCount++; + if (runCount >= 1) { + // Abort after first run + abortCtrl.abort(); + } + return Promise.resolve(); + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should only have the initial run, then abort prevents retries + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent for in_progress tasks (not just pending)', async () => { + const featureWithInProgressTask: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + ], + tasksCompleted: 1, + currentTaskId: 'T002', + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithInProgressTask; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have retried for the in_progress task + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry prompt should mention the in_progress task + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('in_progress'); + }); + + it('uses planningMode skip and no plan approval for retry runs', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planningMode: 'full', + requirePlanApproval: true, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // The retry agent call should use planningMode: 'skip' and requirePlanApproval: false + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + const retryOptions = retryCallArgs[7]; // options object + expect(retryOptions.planningMode).toBe('skip'); + expect(retryOptions.requirePlanApproval).toBe(false); + }); + }); + + describe('executeFeature - error handling', () => { + it('classifies and emits error event', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ + featureId: 'feature-1', + error: 'Test error', + }) + ); + }); + + it('updates status to backlog on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'backlog' + ); + }); + + it('tracks failure and checks pause', async () => { + const testError = new Error('Rate limit error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockTrackFailureFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Rate limit error', + }) + ); + }); + + it('signals pause when threshold reached', async () => { + const testError = new Error('Quota exceeded'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + mockTrackFailureFn = vi.fn().mockReturnValue(true); // threshold reached + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockSignalPauseFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Quota exceeded', + }) + ); + }); + + it('handles abort signal without error event (emits feature_complete when isAutoMode=true)', async () => { + const abortError = new Error('abort'); + abortError.name = 'AbortError'; + mockRunAgentFn = vi.fn().mockRejectedValue(abortError); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', false, true); + + // Should emit feature_complete with stopped by user + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: false, + message: 'Feature stopped by user', + }) + ); + + // Should NOT emit error event + const errorCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_error'); + expect(errorCalls.length).toBe(0); + }); + + it('handles abort signal without emitting feature_complete when isAutoMode=false', async () => { + const abortError = new Error('abort'); + abortError.name = 'AbortError'; + mockRunAgentFn = vi.fn().mockRejectedValue(abortError); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', false, false); + + // Should NOT emit feature_complete when isAutoMode is false + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + + // Should NOT emit error event (abort is not an error) + const errorCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_error'); + expect(errorCalls.length).toBe(0); + }); + + it('releases running feature even on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + }); + + describe('stopFeature', () => { + it('returns false if feature not running', async () => { + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(false); + }); + + it('aborts running feature', async () => { + const runningFeature = createRunningFeature('feature-1'); + const abortSpy = vi.spyOn(runningFeature.abortController, 'abort'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(true); + expect(abortSpy).toHaveBeenCalled(); + }); + + it('releases running feature with force', async () => { + const runningFeature = createRunningFeature('feature-1'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + await service.stopFeature('feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true }); + }); + + it('immediately updates feature status to interrupted before subprocess terminates', async () => { + const runningFeature = createRunningFeature('feature-1'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + await service.stopFeature('feature-1'); + + // Should update to 'interrupted' immediately so the UI reflects the stop + // without waiting for the CLI subprocess to fully terminate + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'interrupted' + ); + }); + + it('still aborts and releases even if status update fails', async () => { + const runningFeature = createRunningFeature('feature-1'); + const abortSpy = vi.spyOn(runningFeature.abortController, 'abort'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + vi.mocked(mockUpdateFeatureStatusFn).mockRejectedValueOnce(new Error('disk error')); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(true); + expect(abortSpy).toHaveBeenCalled(); + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true }); + }); + }); + + describe('worktree resolution', () => { + it('uses worktree when useWorktrees is true and branch exists', async () => { + await service.executeFeature('/test/project', 'feature-1', true); + + expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1' + ); + }); + + it('falls back to project path when worktree not found', async () => { + vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null); + + await service.executeFeature('/test/project', 'feature-1', true); + + // Should still run agent, just with project path + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + // First argument is workDir - should be normalized path to /test/project + expect(callArgs[0]).toBe(normalizePath('/test/project')); + }); + + it('skips worktree resolution when useWorktrees is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false); + + expect(mockWorktreeResolver.findWorktreeForBranch).not.toHaveBeenCalled(); + }); + }); + + describe('auto-mode integration', () => { + it('saves execution state when isAutoMode is true', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockSaveExecutionStateFn).toHaveBeenCalledWith('/test/project'); + }); + + it('saves execution state after completion in auto-mode', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + // Should be called twice: once at start, once at end + expect(mockSaveExecutionStateFn).toHaveBeenCalledTimes(2); + }); + + it('does not save execution state when isAutoMode is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false, false); + + expect(mockSaveExecutionStateFn).not.toHaveBeenCalled(); + }); + }); + + describe('planning mode', () => { + it('calls getPlanningPromptPrefix for features', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockGetPlanningPromptPrefixFn).toHaveBeenCalledWith(testFeature); + }); + + it('emits planning_started event when planning mode is not skip', async () => { + const featureWithPlanning: Feature = { + ...testFeature, + planningMode: 'lite', + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPlanning); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'planning_started', + expect.objectContaining({ + featureId: 'feature-1', + mode: 'lite', + }) + ); + }); + }); + + describe('summary extraction', () => { + it('extracts and saves summary from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('records learnings from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordLearningsFn).toHaveBeenCalledWith( + '/test/project', + testFeature, + 'Agent output' + ); + }); + + it('handles missing agent output gracefully', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + // Should not throw (isAutoMode=true so event is emitted) + await service.executeFeature('/test/project', 'feature-1', false, true); + + // Feature should still complete successfully + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ passes: true }) + ); + }); + }); + + describe('executeFeature - agent output validation', () => { + // Helper to generate realistic agent output with tool markers + const makeAgentOutput = (toolCount: number, extraText = ''): string => { + let output = 'Starting implementation...\n\n'; + for (let i = 0; i < toolCount; i++) { + output += `🔧 Tool: Edit\nInput: {"file_path": "/src/file${i}.ts", "old_string": "old${i}", "new_string": "new${i}"}\n\n`; + } + output += `Implementation complete. ${extraText}`; + return output; + }; + + const createServiceWithMocks = () => { + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('sets verified when agent output has tool usage and sufficient length', async () => { + const output = makeAgentOutput(3, 'Updated authentication module with new login flow.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('sets waiting_approval when agent output is empty', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output has no tool usage markers', async () => { + // Long output but no tool markers - agent printed text but didn't use tools + const longOutputNoTools = 'I analyzed the codebase and found several issues. '.repeat(20); + vi.mocked(secureFs.readFile).mockResolvedValue(longOutputNoTools); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output has tool markers but is too short', async () => { + // Has a tool marker but total output is under 200 chars + const shortWithTool = '🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\nDone.'; + expect(shortWithTool.trim().length).toBeLessThan(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(shortWithTool); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output file is missing (ENOENT)', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output is only whitespace', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(' \n\n\t \n '); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets verified when output is exactly at the 200 char threshold with tool usage', async () => { + // Create output that's exactly 200 chars trimmed with tool markers + const toolMarker = '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n'; + const padding = 'x'.repeat(200 - toolMarker.length); + const output = toolMarker + padding; + expect(output.trim().length).toBeGreaterThanOrEqual(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('sets waiting_approval when output is 199 chars with tool usage (below threshold)', async () => { + const toolMarker = '🔧 Tool: Read\n'; + const padding = 'x'.repeat(199 - toolMarker.length); + const output = toolMarker + padding; + expect(output.trim().length).toBe(199); + + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('skipTests always takes priority over output validation', async () => { + // Meaningful output with tool usage - would normally be 'verified' + const output = makeAgentOutput(5, 'All changes applied successfully.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + // skipTests=true always means waiting_approval regardless of output quality + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('skipTests with empty output still results in waiting_approval', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('still records success even when output validation fails', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // recordSuccess should still be called - the agent ran without errors + expect(mockRecordSuccessFn).toHaveBeenCalled(); + }); + + it('still extracts summary when output has content but no tool markers', async () => { + const outputNoTools = 'A '.repeat(150); // > 200 chars but no tool markers + vi.mocked(secureFs.readFile).mockResolvedValue(outputNoTools); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Summary extraction still runs even though status is waiting_approval + expect(extractSummary).toHaveBeenCalledWith(outputNoTools); + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('emits feature_complete with passes=true even when output validation routes to waiting_approval', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + // The agent ran without error - it's still a "pass" from the execution perspective + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ passes: true }) + ); + }); + + it('handles realistic Cursor CLI output that exits quickly', async () => { + // Simulates a Cursor CLI that prints a brief message and exits + const cursorQuickExit = 'Task received. Processing...\nResult: completed successfully.'; + expect(cursorQuickExit.includes('🔧 Tool:')).toBe(false); + + vi.mocked(secureFs.readFile).mockResolvedValue(cursorQuickExit); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // No tool usage = waiting_approval + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('handles realistic Claude SDK output with multiple tool uses', async () => { + // Simulates a Claude SDK agent that does real work + const claudeOutput = + "I'll implement the requested feature.\n\n" + + '🔧 Tool: Read\nInput: {"file_path": "/src/components/App.tsx"}\n\n' + + 'I can see the existing component structure. Let me modify it.\n\n' + + '🔧 Tool: Edit\nInput: {"file_path": "/src/components/App.tsx", "old_string": "const App = () => {", "new_string": "const App: React.FC = () => {"}\n\n' + + '🔧 Tool: Write\nInput: {"file_path": "/src/components/NewFeature.tsx"}\n\n' + + "I've created the new component and updated the existing one. The feature is now implemented with proper TypeScript types."; + + vi.mocked(secureFs.readFile).mockResolvedValue(claudeOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Real work = verified + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('reads agent output from the correct path with utf-8 encoding', async () => { + const output = makeAgentOutput(2, 'Done with changes.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Verify readFile was called with the correct path derived from getFeatureDir + expect(secureFs.readFile).toHaveBeenCalledWith( + '/test/project/.automaker/features/feature-1/agent-output.md', + 'utf-8' + ); + }); + + it('completion message includes auto-verified when status is verified', async () => { + const output = makeAgentOutput(3, 'All changes applied.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + message: expect.stringContaining('auto-verified'), + }) + ); + }); + + it('completion message does NOT include auto-verified when status is waiting_approval', async () => { + // Empty output → waiting_approval + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + const completeCall = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.find((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCall).toBeDefined(); + expect((completeCall![1] as { message: string }).message).not.toContain('auto-verified'); + }); + + it('uses same agentOutput for both status determination and summary extraction', async () => { + // Specific output that is long enough with tool markers (verified path) + // AND has content for summary extraction + const specificOutput = + '🔧 Tool: Read\nReading file...\n🔧 Tool: Edit\nEditing file...\n' + + 'The implementation is complete. Here is a detailed description of what was done. '.repeat( + 3 + ); + vi.mocked(secureFs.readFile).mockResolvedValue(specificOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Status should be verified (has tools + long enough) + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + // extractSummary should receive the exact same output + expect(extractSummary).toHaveBeenCalledWith(specificOutput); + // recordLearnings should also receive the same output + expect(mockRecordLearningsFn).toHaveBeenCalledWith( + '/test/project', + testFeature, + specificOutput + ); + }); + + it('does not call recordMemoryUsage when output is empty and memoryFiles is empty', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + const { recordMemoryUsage } = await import('@automaker/utils'); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // With empty output and empty memoryFiles, recordMemoryUsage should not be called + expect(recordMemoryUsage).not.toHaveBeenCalled(); + }); + + it('handles output with special unicode characters correctly', async () => { + // Output with various unicode but includes tool markers + const unicodeOutput = + '🔧 Tool: Read\n' + + '🔧 Tool: Edit\n' + + 'Añadiendo función de búsqueda con caracteres especiales: ñ, ü, ö, é, 日本語テスト. ' + + 'Die Änderungen wurden erfolgreich implementiert. '.repeat(3); + vi.mocked(secureFs.readFile).mockResolvedValue(unicodeOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should still detect tool markers and sufficient length + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('treats output with only newlines and spaces around tool marker as insufficient', async () => { + // Has tool marker but surrounded by whitespace, total trimmed < 200 + const sparseOutput = '\n\n 🔧 Tool: Read \n\n'; + expect(sparseOutput.trim().length).toBeLessThan(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(sparseOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('detects tool marker substring correctly (partial match like "🔧 Tools:" does not count)', async () => { + // Output with a similar but not exact marker - "🔧 Tools:" instead of "🔧 Tool:" + const wrongMarker = '🔧 Tools: Read\n🔧 Tools: Edit\n' + 'Implementation done. '.repeat(20); + expect(wrongMarker.includes('🔧 Tool:')).toBe(false); + + vi.mocked(secureFs.readFile).mockResolvedValue(wrongMarker); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // "🔧 Tools:" is not the same as "🔧 Tool:" - should be waiting_approval + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('pipeline merge_conflict status short-circuits before output validation', async () => { + // Set up pipeline that results in merge_conflict + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any, + }); + + // After pipeline, loadFeature returns merge_conflict status + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount === 1) return testFeature; // initial load + // All subsequent loads (task check + pipeline refresh) return merge_conflict + return { ...testFeature, status: 'merge_conflict' }; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should NOT have called updateFeatureStatusFn with 'verified' or 'waiting_approval' + // because pipeline merge_conflict short-circuits the method + const statusCalls = vi + .mocked(mockUpdateFeatureStatusFn) + .mock.calls.filter((call) => call[2] === 'verified' || call[2] === 'waiting_approval'); + // The only non-in_progress status call should be absent since merge_conflict returns early + expect(statusCalls.length).toBe(0); + }); + }); +}); diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts new file mode 100644 index 00000000..6abd4764 --- /dev/null +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -0,0 +1,760 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import path from 'path'; +import { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '@/lib/events.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; +import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; +import { getNotificationService } from '@/services/notification-service.js'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + * Uses path.normalize (not path.resolve) to match path.join behavior in production code. + */ +const normalizePath = (p: string): string => path.normalize(p); + +// Mock dependencies +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + atomicWriteJson: vi.fn(), + readJsonWithRecovery: vi.fn(), + logRecoveryWarning: vi.fn(), + }; +}); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), + getFeaturesDir: vi.fn(), +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: vi.fn(), + })), +})); + +describe('FeatureStateManager', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + let mockFeatureLoader: FeatureLoader; + + const mockFeature: Feature = { + id: 'feature-123', + name: 'Test Feature', + title: 'Test Feature Title', + description: 'A test feature', + status: 'pending', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Default mocks + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (getFeaturesDir as Mock).mockReturnValue('/project/.automaker/features'); + }); + + describe('loadFeature', () => { + it('should load feature from disk', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ data: mockFeature, recovered: false }); + + const feature = await manager.loadFeature('/project', 'feature-123'); + + expect(feature).toEqual(mockFeature); + expect(getFeatureDir).toHaveBeenCalledWith('/project', 'feature-123'); + expect(readJsonWithRecovery).toHaveBeenCalledWith( + normalizePath('/project/.automaker/features/feature-123/feature.json'), + null, + expect.objectContaining({ autoRestore: true }) + ); + }); + + it('should return null if feature does not exist', async () => { + (readJsonWithRecovery as Mock).mockRejectedValue(new Error('ENOENT')); + + const feature = await manager.loadFeature('/project', 'non-existent'); + + expect(feature).toBeNull(); + }); + + it('should return null if feature JSON is invalid', async () => { + // readJsonWithRecovery returns null as the default value when JSON is invalid + (readJsonWithRecovery as Mock).mockResolvedValue({ data: null, recovered: false }); + + const feature = await manager.loadFeature('/project', 'feature-123'); + + expect(feature).toBeNull(); + }); + }); + + describe('updateFeatureStatus', () => { + it('should update feature status and persist to disk', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'in_progress'); + + expect(atomicWriteJson).toHaveBeenCalled(); + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('in_progress'); + expect(savedFeature.updatedAt).toBeDefined(); + }); + + it('should set justFinishedAt when status is waiting_approval', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.justFinishedAt).toBeDefined(); + }); + + it('should clear justFinishedAt when status is not waiting_approval', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, justFinishedAt: '2024-01-01T00:00:00Z' }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'in_progress'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.justFinishedAt).toBeUndefined(); + }); + + it('should finalize in_progress tasks but keep pending tasks when moving to waiting_approval', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Already completed tasks stay completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + // pending tasks should remain pending (never started) + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal actual completed tasks count + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); + }); + + it('should finalize tasks when moving to verified status', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Already completed tasks stay completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + // pending tasks should remain pending (never started) + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal actual completed tasks count + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); + // justFinishedAt should be cleared for verified + expect(savedFeature.justFinishedAt).toBeUndefined(); + }); + + it('should handle waiting_approval without planSpec tasks gracefully', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('waiting_approval'); + expect(savedFeature.justFinishedAt).toBeDefined(); + }); + + it('should create notification for waiting_approval status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + featureId: 'feature-123', + }) + ); + }); + + it('should create notification for verified status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + featureId: 'feature-123', + }) + ); + }); + + it('should sync to app_spec for completed status', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'completed'); + + expect(mockFeatureLoader.syncFeatureToAppSpec).toHaveBeenCalledWith( + '/project', + expect.objectContaining({ status: 'completed' }) + ); + }); + + it('should sync to app_spec for verified status', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockFeatureLoader.syncFeatureToAppSpec).toHaveBeenCalled(); + }); + + it('should not fail if sync to app_spec fails', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + (mockFeatureLoader.syncFeatureToAppSpec as Mock).mockRejectedValue(new Error('Sync failed')); + + // Should not throw + await expect( + manager.updateFeatureStatus('/project', 'feature-123', 'completed') + ).resolves.not.toThrow(); + }); + + it('should handle feature not found gracefully', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: null, + recovered: true, + source: 'default', + }); + + // Should not throw + await expect( + manager.updateFeatureStatus('/project', 'non-existent', 'in_progress') + ).resolves.not.toThrow(); + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + }); + + describe('markFeatureInterrupted', () => { + it('should mark feature as interrupted', async () => { + (secureFs.readFile as Mock).mockResolvedValue( + JSON.stringify({ ...mockFeature, status: 'in_progress' }) + ); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'in_progress' }, + recovered: false, + source: 'main', + }); + + await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown'); + + expect(atomicWriteJson).toHaveBeenCalled(); + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('interrupted'); + }); + + it('should preserve pipeline_* statuses', async () => { + (secureFs.readFile as Mock).mockResolvedValue( + JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' }) + ); + + await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown'); + + // Should NOT call atomicWriteJson because pipeline status is preserved + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + + it('should preserve pipeline_complete status', async () => { + (secureFs.readFile as Mock).mockResolvedValue( + JSON.stringify({ ...mockFeature, status: 'pipeline_complete' }) + ); + + await manager.markFeatureInterrupted('/project', 'feature-123'); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + + it('should handle feature not found', async () => { + (secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT')); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: null, + recovered: true, + source: 'default', + }); + + // Should not throw + await expect( + manager.markFeatureInterrupted('/project', 'non-existent') + ).resolves.not.toThrow(); + }); + }); + + describe('resetStuckFeatures', () => { + it('should reset in_progress features to ready if has approved plan', async () => { + const stuckFeature: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { status: 'approved', version: 1, reviewedByUser: true }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: stuckFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + expect(atomicWriteJson).toHaveBeenCalled(); + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('ready'); + }); + + it('should reset in_progress features to backlog if no approved plan', async () => { + const stuckFeature: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: undefined, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: stuckFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('backlog'); + }); + + it('should reset generating planSpec status to pending', async () => { + const stuckFeature: Feature = { + ...mockFeature, + status: 'pending', + planSpec: { status: 'generating', version: 1, reviewedByUser: false }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: stuckFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.status).toBe('pending'); + }); + + it('should reset in_progress tasks to pending', async () => { + const stuckFeature: Feature = { + ...mockFeature, + status: 'pending', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: '' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: '' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: '' }, + ], + currentTaskId: 'task-2', + }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: stuckFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('pending'); + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + }); + + it('should skip non-directory entries', async () => { + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + { name: 'some-file.txt', isDirectory: () => false }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: mockFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + // Should only process the directory + expect(readJsonWithRecovery).toHaveBeenCalledTimes(1); + }); + + it('should handle features directory not existing', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + (secureFs.readdir as Mock).mockRejectedValue(error); + + // Should not throw + await expect(manager.resetStuckFeatures('/project')).resolves.not.toThrow(); + }); + + it('should not update feature if nothing is stuck', async () => { + const normalFeature: Feature = { + ...mockFeature, + status: 'completed', + planSpec: { status: 'approved', version: 1, reviewedByUser: true }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: normalFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + }); + + describe('updateFeaturePlanSpec', () => { + it('should update planSpec with partial updates', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeaturePlanSpec('/project', 'feature-123', { status: 'approved' }); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.status).toBe('approved'); + }); + + it('should initialize planSpec if not exists', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, planSpec: undefined }, + recovered: false, + source: 'main', + }); + + await manager.updateFeaturePlanSpec('/project', 'feature-123', { status: 'approved' }); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec).toBeDefined(); + expect(savedFeature.planSpec?.version).toBe(1); + }); + + it('should increment version when content changes', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...mockFeature, + planSpec: { + status: 'pending', + version: 2, + content: 'old content', + reviewedByUser: false, + }, + }, + recovered: false, + source: 'main', + }); + + await manager.updateFeaturePlanSpec('/project', 'feature-123', { content: 'new content' }); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.version).toBe(3); + }); + }); + + describe('saveFeatureSummary', () => { + it('should save summary and emit event', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'This is the summary'); + + // Verify persisted + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('This is the summary'); + + // Verify event emitted AFTER persistence + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'feature-123', + projectPath: '/project', + summary: 'This is the summary', + }); + }); + + it('should handle feature not found', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: null, + recovered: true, + source: 'default', + }); + + await expect( + manager.saveFeatureSummary('/project', 'non-existent', 'Summary') + ).resolves.not.toThrow(); + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + }); + + describe('updateTaskStatus', () => { + it('should update task status and emit event', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'pending', description: '' }, + { id: 'task-2', title: 'Task 2', status: 'pending', description: '' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateTaskStatus('/project', 'feature-123', 'task-1', 'completed'); + + // Verify persisted + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + + // Verify event emitted + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_task_status', + featureId: 'feature-123', + projectPath: '/project', + taskId: 'task-1', + status: 'completed', + tasks: expect.any(Array), + }); + }); + + it('should handle task not found', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateTaskStatus('/project', 'feature-123', 'non-existent-task', 'completed'); + + // Should not persist or emit if task not found + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + + it('should handle feature without tasks', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await expect( + manager.updateTaskStatus('/project', 'feature-123', 'task-1', 'completed') + ).resolves.not.toThrow(); + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + }); + + describe('persist BEFORE emit ordering', () => { + it('saveFeatureSummary should persist before emitting event', async () => { + const callOrder: string[] = []; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockImplementation(async () => { + callOrder.push('persist'); + }); + (mockEvents.emit as Mock).mockImplementation(() => { + callOrder.push('emit'); + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Summary'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); + + it('updateTaskStatus should persist before emitting event', async () => { + const callOrder: string[] = []; + + const featureWithTasks: Feature = { + ...mockFeature, + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockImplementation(async () => { + callOrder.push('persist'); + }); + (mockEvents.emit as Mock).mockImplementation(() => { + callOrder.push('emit'); + }); + + await manager.updateTaskStatus('/project', 'feature-123', 'task-1', 'completed'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); + }); +}); diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts index 1be24cbe..7004362a 100644 --- a/apps/server/tests/unit/services/ideation-service.test.ts +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -25,7 +25,7 @@ const mockLogger = vi.hoisted(() => ({ const mockCreateChatOptions = vi.hoisted(() => vi.fn(() => ({ - model: 'claude-sonnet-4-20250514', + model: 'claude-sonnet-4-6', systemPrompt: 'test prompt', })) ); diff --git a/apps/server/tests/unit/services/pipeline-orchestrator.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts new file mode 100644 index 00000000..d4f34265 --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts @@ -0,0 +1,1141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type PipelineStatusInfo, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import { pipelineService } from '../../../src/services/pipeline-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +// Mock merge-service +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn(), +})); + +import { performMerge } from '../../../src/services/merge-service.js'; + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock validateWorkingDirectory +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockAgentExecutor: AgentExecutor; + let mockTestRunnerService: TestRunnerService; + let mockWorktreeResolver: WorktreeResolver; + let mockConcurrencyManager: ConcurrencyManager; + let mockSettingsService: SettingsService | null; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadContextFilesFn: vi.Mock; + let mockBuildFeaturePromptFn: BuildFeaturePromptFn; + let mockExecuteFeatureFn: ExecuteFeatureFn; + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + // Test data + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_step-1', + branchName: 'feature/test-1', + }; + + const testSteps: PipelineStep[] = [ + { + id: 'step-1', + name: 'Step 1', + order: 1, + instructions: 'Do step 1', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + { + id: 'step-2', + name: 'Step 2', + order: 2, + instructions: 'Do step 2', + colorClass: 'green', + createdAt: '', + updatedAt: '', + }, + ]; + + const testConfig: PipelineConfig = { + version: 1, + steps: testSteps, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(testFeature), + } as unknown as FeatureStateManager; + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue({ success: true }), + } as unknown as AgentExecutor; + + mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager; + + mockSettingsService = null; + + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' }); + mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content'); + mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + // Default mocks for secureFs + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Re-setup platform mocks (clearAllMocks resets implementations) + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + + // Re-setup settings helpers mocks + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + mockSettingsService, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + expect(orchestrator).toBeInstanceOf(PipelineOrchestrator); + }); + + it('should accept null settingsService', () => { + const orch = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + null, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + expect(orch).toBeInstanceOf(PipelineOrchestrator); + }); + }); + + describe('buildPipelineStepPrompt', () => { + const taskPrompts = { + implementationInstructions: 'impl instructions', + playwrightVerificationInstructions: 'playwright instructions', + }; + + it('should include step name and instructions', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + '', + taskPrompts + ); + expect(prompt).toContain('## Pipeline Step: Step 1'); + expect(prompt).toContain('Do step 1'); + }); + + it('should include feature context from callback', () => { + orchestrator.buildPipelineStepPrompt(testSteps[0], testFeature, '', taskPrompts); + expect(mockBuildFeaturePromptFn).toHaveBeenCalledWith(testFeature, taskPrompts); + }); + + it('should include previous context when available', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + 'Previous work content', + taskPrompts + ); + expect(prompt).toContain('### Previous Work'); + expect(prompt).toContain('Previous work content'); + }); + + it('should omit previous context section when empty', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + '', + taskPrompts + ); + expect(prompt).not.toContain('### Previous Work'); + }); + }); + + describe('detectPipelineStatus', () => { + beforeEach(() => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1'); + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig); + }); + + it('should return isPipeline false for non-pipeline status', async () => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(result.isPipeline).toBe(false); + expect(result.stepId).toBeNull(); + }); + + it('should return step info for valid pipeline status', async () => { + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(result.isPipeline).toBe(true); + expect(result.stepId).toBe('step-1'); + expect(result.stepIndex).toBe(0); + expect(result.step?.name).toBe('Step 1'); + }); + + it('should return stepIndex -1 when step not found in config', async () => { + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('nonexistent-step'); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_nonexistent' + ); + expect(result.isPipeline).toBe(true); + expect(result.stepIndex).toBe(-1); + expect(result.step).toBeNull(); + }); + + it('should return config null when no pipeline config exists', async () => { + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(null); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(result.isPipeline).toBe(true); + expect(result.config).toBeNull(); + expect(result.stepIndex).toBe(-1); + }); + }); + + describe('executeTestStep', () => { + const createTestContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + it('should return success when tests pass on first attempt', async () => { + const context = createTestContext(); + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(true); + expect(result.testsPassed).toBe(true); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(1); + }, 10000); + + it('should retry with agent fix when tests fail', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const context = createTestContext(); + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(true); + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2); + }, 15000); + + it('should fail after max attempts', async () => { + vi.mocked(mockTestRunnerService.getSession).mockReturnValue({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + // Use smaller maxTestAttempts to speed up test + const context = { ...createTestContext(), maxTestAttempts: 2 }; + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(false); + expect(result.testsPassed).toBe(false); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2); + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }, 15000); + + it('should emit pipeline_test_failed event on each failure', async () => { + vi.mocked(mockTestRunnerService.getSession).mockReturnValue({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + // Use smaller maxTestAttempts to speed up test + const context = { ...createTestContext(), maxTestAttempts: 2 }; + await orchestrator.executeTestStep(context, 'npm test'); + + const testFailedCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_test_failed'); + expect(testFailedCalls.length).toBe(2); + }, 15000); + + it('should build test failure summary for agent', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + vi.mocked(mockTestRunnerService.getSessionOutput).mockReturnValue({ + success: true, + result: { output: 'FAIL test.spec.ts\nExpected 1 to be 2' }, + } as never); + + const context = createTestContext(); + await orchestrator.executeTestStep(context, 'npm test'); + + const fixPromptCall = vi.mocked(mockRunAgentFn).mock.calls[0]; + expect(fixPromptCall[2]).toContain('Test Failures'); + }, 15000); + }); + + describe('attemptMerge', () => { + const createMergeContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: '/test/worktree', + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockReset(); + }); + + it('should call performMerge with correct parameters', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('should return success on clean merge', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + + const context = createMergeContext(); + const result = await orchestrator.attemptMerge(context); + + expect(result.success).toBe(true); + expect(result.hasConflicts).toBeUndefined(); + }); + + it('should set merge_conflict status when hasConflicts is true', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'merge_conflict' + ); + }); + + it('should emit pipeline_merge_conflict event on conflict', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'pipeline_merge_conflict', + expect.objectContaining({ featureId: 'feature-1', branchName: 'feature/test-1' }) + ); + }); + + it('should emit auto_mode_feature_complete on success when isAutoMode is true', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ featureId: 'feature-1', passes: true }) + ); + }); + + it('should not emit auto_mode_feature_complete on success when isAutoMode is false', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + + it('should return needsAgentResolution true on conflict', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + const result = await orchestrator.attemptMerge(context); + + expect(result.needsAgentResolution).toBe(true); + }); + }); + + describe('buildTestFailureSummary', () => { + it('should extract pass/fail counts from test output', () => { + const scrollback = ` + PASS tests/passing.test.ts + FAIL tests/failing.test.ts + FAIL tests/another.test.ts + `; + + const summary = orchestrator.buildTestFailureSummary(scrollback); + expect(summary).toContain('1 passed'); + expect(summary).toContain('2 failed'); + }); + + it('should extract failed test names from output', () => { + const scrollback = ` + FAIL tests/auth.test.ts + FAIL tests/user.test.ts + `; + + const summary = orchestrator.buildTestFailureSummary(scrollback); + expect(summary).toContain('tests/auth.test.ts'); + expect(summary).toContain('tests/user.test.ts'); + }); + + it('should return concise summary for agent', () => { + const longOutput = 'x'.repeat(5000); + const summary = orchestrator.buildTestFailureSummary(longOutput); + + expect(summary.length).toBeLessThan(5000); + expect(summary).toContain('Output (last 2000 chars)'); + }); + }); + + describe('resumePipeline', () => { + const validPipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + it('should restart from beginning when no context file', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + await orchestrator.resumePipeline('/test/project', testFeature, true, validPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(mockExecuteFeatureFn).toHaveBeenCalled(); + }); + + it('should complete feature when step no longer exists and emit event when isAutoMode=true', async () => { + const invalidPipelineInfo: PipelineStatusInfo = { + ...validPipelineInfo, + stepIndex: -1, + step: null, + }; + + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ message: expect.stringContaining('no longer exists') }) + ); + }); + + it('should not emit feature_complete when step no longer exists and isAutoMode=false', async () => { + const invalidPipelineInfo: PipelineStatusInfo = { + ...validPipelineInfo, + stepIndex: -1, + step: null, + }; + + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + }); + + describe('resumeFromStep', () => { + it('should filter out excluded steps', async () => { + const featureWithExclusions: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('pipeline_step-2'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-2'); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithExclusions, + true, + 0, + testConfig + ); + + expect(mockRunAgentFn).toHaveBeenCalled(); + }); + + it('should complete feature when all remaining steps excluded and emit event when isAutoMode=true', async () => { + const featureWithAllExcluded: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1', 'step-2'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithAllExcluded, + true, + 0, + testConfig + ); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ message: expect.stringContaining('excluded') }) + ); + }); + + it('should acquire running feature slot before execution', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith( + expect.objectContaining({ featureId: 'feature-1', allowReuse: true }) + ); + }); + + it('should release slot on completion', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1'); + }); + + it('should release slot on error', async () => { + mockRunAgentFn.mockRejectedValue(new Error('Test error')); + + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1'); + }); + }); + + describe('executePipeline', () => { + const createPipelineContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + }); + + it('should execute steps in sequence', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + }); + + it('should emit pipeline_step_started for each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + const startedCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_step_started'); + expect(startedCalls.length).toBe(2); + }); + + it('should emit pipeline_step_complete after each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_step_complete'); + expect(completeCalls.length).toBe(2); + }); + + it('should update feature status to pipeline_{stepId} for each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'pipeline_step-2' + ); + }); + + it('should respect abort signal between steps', async () => { + const context = createPipelineContext(); + mockRunAgentFn.mockImplementation(async () => { + context.abortController.abort(); + }); + + await expect(orchestrator.executePipeline(context)).rejects.toThrow( + 'Pipeline execution aborted' + ); + }); + + it('should call attemptMerge after successful completion', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/project', // Falls back to projectPath when worktreePath is null + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + }); + + describe('AutoModeService integration (delegation verification)', () => { + describe('executePipeline delegation', () => { + const createPipelineContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: '/test/worktree', + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + }); + + it('builds PipelineContext with correct fields from executeFeature', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + // Verify all context fields were used correctly + expect(context.projectPath).toBe('/test/project'); + expect(context.featureId).toBe('feature-1'); + expect(context.steps).toHaveLength(2); + expect(context.workDir).toBe('/test/project'); + expect(context.worktreePath).toBe('/test/worktree'); + expect(context.branchName).toBe('feature/test-1'); + expect(context.autoLoadClaudeMd).toBe(true); + expect(context.testAttempts).toBe(0); + expect(context.maxTestAttempts).toBe(5); + }); + + it('passes worktreePath when worktree exists', async () => { + const context = createPipelineContext(); + context.worktreePath = '/test/custom-worktree'; + + await orchestrator.executePipeline(context); + + // Merge should receive the worktree path + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/custom-worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('passes branchName from feature', async () => { + const context = createPipelineContext(); + context.branchName = 'feature/custom-branch'; + context.feature = { ...testFeature, branchName: 'feature/custom-branch' }; + + await orchestrator.executePipeline(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/custom-branch', + '/test/worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('passes testAttempts and maxTestAttempts', async () => { + const context = createPipelineContext(); + context.testAttempts = 2; + context.maxTestAttempts = 10; + + // These values would be used by executeTestStep if called + expect(context.testAttempts).toBe(2); + expect(context.maxTestAttempts).toBe(10); + }); + }); + + describe('detectPipelineStatus delegation', () => { + beforeEach(() => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1'); + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig); + }); + + it('returns pipelineInfo from orchestrator for pipeline status', async () => { + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + + expect(result.isPipeline).toBe(true); + expect(result.stepId).toBe('step-1'); + expect(result.stepIndex).toBe(0); + expect(result.config).toEqual(testConfig); + }); + + it('returns isPipeline false for non-pipeline status', async () => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'in_progress' + ); + + expect(result.isPipeline).toBe(false); + expect(result.stepId).toBeNull(); + expect(result.config).toBeNull(); + }); + }); + + describe('resumePipeline delegation', () => { + const validPipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + it('builds resumeContext with autoLoadClaudeMd setting', async () => { + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // Verify autoLoadClaudeMd was fetched + expect(getAutoLoadClaudeMdSetting).toHaveBeenCalledWith( + '/test/project', + null, + '[AutoMode]' + ); + }); + + it('passes useWorktrees flag to orchestrator', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // When useWorktrees is true, it should look for worktree + expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1' + ); + }); + + it('sets maxTestAttempts to 5', async () => { + // The default maxTestAttempts is 5 as per CONTEXT.md + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // Execution should proceed with maxTestAttempts = 5 + expect(mockRunAgentFn).toHaveBeenCalled(); + }); + }); + }); + + describe('edge cases', () => { + describe('abort signal handling', () => { + it('handles abort signal during step execution', async () => { + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + // Abort during first step + mockRunAgentFn.mockImplementationOnce(async () => { + context.abortController.abort(); + }); + + await expect(orchestrator.executePipeline(context)).rejects.toThrow( + 'Pipeline execution aborted' + ); + }); + }); + + describe('context file handling', () => { + it('handles missing context file during resume', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + const pipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo); + + // Should restart from beginning when no context + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(mockExecuteFeatureFn).toHaveBeenCalled(); + }); + }); + + describe('step deletion handling', () => { + it('handles deleted step during resume', async () => { + const pipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'deleted-step', + stepIndex: -1, + totalSteps: 2, + step: null, + config: testConfig, + }; + + await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo); + + // Should complete feature when step no longer exists + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('handles all steps excluded during resume and emits event when isAutoMode=true', async () => { + const featureWithAllExcluded: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1', 'step-2'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithAllExcluded, + true, + 0, + testConfig + ); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + message: expect.stringContaining('excluded'), + }) + ); + }); + }); + }); +}); diff --git a/apps/server/tests/unit/services/plan-approval-service.test.ts b/apps/server/tests/unit/services/plan-approval-service.test.ts new file mode 100644 index 00000000..d5379755 --- /dev/null +++ b/apps/server/tests/unit/services/plan-approval-service.test.ts @@ -0,0 +1,470 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { PlanApprovalService } from '@/services/plan-approval-service.js'; +import type { TypedEventBus } from '@/services/typed-event-bus.js'; +import type { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { SettingsService } from '@/services/settings-service.js'; +import type { Feature } from '@automaker/types'; + +describe('PlanApprovalService', () => { + let service: PlanApprovalService; + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockSettingsService: SettingsService | null; + + beforeEach(() => { + vi.useFakeTimers(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + getUnderlyingEmitter: vi.fn(), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + loadFeature: vi.fn(), + updateFeatureStatus: vi.fn(), + updateFeaturePlanSpec: vi.fn(), + } as unknown as FeatureStateManager; + + mockSettingsService = { + getProjectSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + service = new PlanApprovalService(mockEventBus, mockFeatureStateManager, mockSettingsService); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // Helper to flush pending promises + const flushPromises = () => vi.runAllTimersAsync(); + + describe('waitForApproval', () => { + it('should create pending entry and return Promise', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Flush async operations so the approval is registered + await vi.advanceTimersByTimeAsync(0); + + expect(service.hasPendingApproval('feature-1')).toBe(true); + expect(approvalPromise).toBeInstanceOf(Promise); + }); + + it('should timeout and reject after configured period', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush the async initialization + await vi.advanceTimersByTimeAsync(0); + + // Advance time by 30 minutes + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval timed out after 30 minutes - feature execution cancelled' + ); + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should use configured timeout from project settings', async () => { + // Configure 10 minute timeout + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: 10 * 60 * 1000, + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush the async initialization + await vi.advanceTimersByTimeAsync(0); + + // Advance time by 10 minutes - should timeout + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval timed out after 10 minutes - feature execution cancelled' + ); + }); + + it('should fall back to default timeout when settings service is null', async () => { + // Create service without settings service + const serviceNoSettings = new PlanApprovalService( + mockEventBus, + mockFeatureStateManager, + null + ); + + const approvalPromise = serviceNoSettings.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush async + await vi.advanceTimersByTimeAsync(0); + + // Advance by 29 minutes - should not timeout yet + await vi.advanceTimersByTimeAsync(29 * 60 * 1000); + expect(serviceNoSettings.hasPendingApproval('feature-1')).toBe(true); + + // Advance by 1 more minute (total 30) - should timeout + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow('Plan approval timed out'); + }); + }); + + describe('resolveApproval', () => { + it('should resolve Promise correctly when approved=true', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.resolveApproval('feature-1', true, { + editedPlan: 'Updated plan', + feedback: 'Looks good!', + }); + + expect(result).toEqual({ success: true }); + + const approval = await approvalPromise; + expect(approval).toEqual({ + approved: true, + editedPlan: 'Updated plan', + feedback: 'Looks good!', + }); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should resolve Promise correctly when approved=false', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.resolveApproval('feature-1', false, { + feedback: 'Need more details', + }); + + expect(result).toEqual({ success: true }); + + const approval = await approvalPromise; + expect(approval).toEqual({ + approved: false, + editedPlan: undefined, + feedback: 'Need more details', + }); + }); + + it('should emit plan_rejected event when rejected with feedback', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', false, { + feedback: 'Need changes', + }); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('plan_rejected', { + featureId: 'feature-1', + projectPath: '/project', + feedback: 'Need changes', + }); + }); + + it('should update planSpec status to approved when approved', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', true, { + editedPlan: 'New plan content', + }); + + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'approved', + reviewedByUser: true, + content: 'New plan content', + }) + ); + }); + + it('should update planSpec status to rejected when rejected', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', false); + + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'rejected', + reviewedByUser: true, + }) + ); + }); + + it('should clear timeout on normal resolution (no double-fire)', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + // Advance 10 minutes then resolve + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + await service.resolveApproval('feature-1', true); + + const approval = await approvalPromise; + expect(approval.approved).toBe(true); + + // Advance past the 30 minute mark - should NOT reject + await vi.advanceTimersByTimeAsync(25 * 60 * 1000); + + // If timeout wasn't cleared, we'd see issues + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return error when no pending approval and no recovery possible', async () => { + const result = await service.resolveApproval('non-existent', true); + + expect(result).toEqual({ + success: false, + error: 'No pending approval for feature non-existent', + }); + }); + }); + + describe('recovery path', () => { + it('should return needsRecovery=true when planSpec.status is generated and approved', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'in_progress', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'generated', + version: 1, + reviewedByUser: false, + content: 'Original plan', + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + // No pending approval in Map, but feature has generated planSpec + const result = await service.resolveApproval('feature-1', true, { + projectPath: '/project', + editedPlan: 'Edited plan', + }); + + expect(result).toEqual({ success: true, needsRecovery: true }); + + // Should update planSpec + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'approved', + content: 'Edited plan', + }) + ); + }); + + it('should handle recovery rejection correctly', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'in_progress', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'generated', + version: 1, + reviewedByUser: false, + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + const result = await service.resolveApproval('feature-1', false, { + projectPath: '/project', + feedback: 'Rejected via recovery', + }); + + expect(result).toEqual({ success: true }); // No needsRecovery for rejections + + // Should update planSpec to rejected + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'rejected', + reviewedByUser: true, + }) + ); + + // Should update feature status to backlog + expect(mockFeatureStateManager.updateFeatureStatus).toHaveBeenCalledWith( + '/project', + 'feature-1', + 'backlog' + ); + + // Should emit plan_rejected event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('plan_rejected', { + featureId: 'feature-1', + projectPath: '/project', + feedback: 'Rejected via recovery', + }); + }); + + it('should not trigger recovery when planSpec.status is not generated', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'pending', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'pending', // Not 'generated' + version: 1, + reviewedByUser: false, + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + const result = await service.resolveApproval('feature-1', true, { + projectPath: '/project', + }); + + expect(result).toEqual({ + success: false, + error: 'No pending approval for feature feature-1', + }); + }); + }); + + describe('cancelApproval', () => { + it('should reject pending Promise with cancellation error', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + service.cancelApproval('feature-1'); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval cancelled - feature was stopped' + ); + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should clear timeout on cancellation', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + service.cancelApproval('feature-1'); + + // Verify rejection happened + await expect(approvalPromise).rejects.toThrow(); + + // Advance past timeout - should not cause any issues + await vi.advanceTimersByTimeAsync(35 * 60 * 1000); + + // No additional errors should occur + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should do nothing when no pending approval exists', () => { + // Should not throw + expect(() => service.cancelApproval('non-existent')).not.toThrow(); + }); + }); + + describe('hasPendingApproval', () => { + it('should return true when approval is pending', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + expect(service.hasPendingApproval('feature-1')).toBe(true); + }); + + it('should return false when no approval is pending', () => { + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return false after approval is resolved', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + await service.resolveApproval('feature-1', true); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return false after approval is cancelled', async () => { + const promise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + service.cancelApproval('feature-1'); + + // Consume the rejection + await promise.catch(() => {}); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + }); + + describe('getTimeoutMs (via waitForApproval behavior)', () => { + it('should return configured value from project settings', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: 5 * 60 * 1000, // 5 minutes + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should not timeout at 4 minutes + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + expect(service.hasPendingApproval('feature-1')).toBe(true); + + // Should timeout at 5 minutes + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 5 minutes'); + }); + + it('should return default when settings service throws', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockRejectedValue(new Error('Failed')); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should use default 30 minute timeout + await vi.advanceTimersByTimeAsync(29 * 60 * 1000); + expect(service.hasPendingApproval('feature-1')).toBe(true); + + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 30 minutes'); + }); + + it('should return default when planApprovalTimeoutMs is invalid', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: -1, // Invalid + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should use default 30 minute timeout + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 30 minutes'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/recovery-service.test.ts b/apps/server/tests/unit/services/recovery-service.test.ts new file mode 100644 index 00000000..cd99fc08 --- /dev/null +++ b/apps/server/tests/unit/services/recovery-service.test.ts @@ -0,0 +1,792 @@ +/** + * Unit tests for RecoveryService + * + * Tests crash recovery and feature resumption functionality: + * - Execution state persistence (save/load/clear) + * - Context detection (agent-output.md exists) + * - Feature resumption flow (pipeline vs non-pipeline) + * - Interrupted feature detection and batch resumption + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import { RecoveryService, DEFAULT_EXECUTION_STATE } from '@/services/recovery-service.js'; +import type { Feature } from '@automaker/types'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + * Uses path.normalize (not path.resolve) to match path.join behavior in production code. + */ +const normalizePath = (p: string): string => path.normalize(p); + +// Mock dependencies +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + readJsonWithRecovery: vi.fn().mockResolvedValue({ data: null, wasRecovered: false }), + logRecoveryWarning: vi.fn(), + DEFAULT_BACKUP_COUNT: 5, +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: (projectPath: string, featureId: string) => + `${projectPath}/.automaker/features/${featureId}`, + getFeaturesDir: (projectPath: string) => `${projectPath}/.automaker/features`, + getExecutionStatePath: (projectPath: string) => `${projectPath}/.automaker/execution-state.json`, + ensureAutomakerDir: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn().mockRejectedValue(new Error('ENOENT')), + readFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + writeFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), +})); + +vi.mock('@/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + resumeFeatureTemplate: 'Resume: {{featurePrompt}}\n\nPrevious context:\n{{previousContext}}', + }, + }), +})); + +describe('recovery-service.ts', () => { + // Import mocked modules for access in tests + let secureFs: typeof import('@/lib/secure-fs.js'); + let utils: typeof import('@automaker/utils'); + + // Mock dependencies + const mockEventBus = { + emitAutoModeEvent: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const mockConcurrencyManager = { + getAllRunning: vi.fn().mockReturnValue([]), + getRunningFeature: vi.fn().mockReturnValue(null), + acquire: vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + abortController: new AbortController(), + projectPath: '/test/project', + isAutoMode: false, + startTime: Date.now(), + leaseCount: 1, + })), + release: vi.fn(), + getRunningCountForWorktree: vi.fn().mockReturnValue(0), + }; + + const mockSettingsService = null; + + // Callback mocks - initialize empty, set up in beforeEach + let mockExecuteFeature: ReturnType; + let mockLoadFeature: ReturnType; + let mockDetectPipelineStatus: ReturnType; + let mockResumePipeline: ReturnType; + let mockIsFeatureRunning: ReturnType; + let mockAcquireRunningFeature: ReturnType; + let mockReleaseRunningFeature: ReturnType; + + let service: RecoveryService; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + secureFs = await import('@/lib/secure-fs.js'); + utils = await import('@automaker/utils'); + + // Reset secure-fs mocks to default behavior + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.unlink).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([]); + + // Reset all callback mocks with default implementations + mockExecuteFeature = vi.fn().mockResolvedValue(undefined); + mockLoadFeature = vi.fn().mockResolvedValue(null); + mockDetectPipelineStatus = vi.fn().mockResolvedValue({ + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }); + mockResumePipeline = vi.fn().mockResolvedValue(undefined); + mockIsFeatureRunning = vi.fn().mockReturnValue(false); + mockAcquireRunningFeature = vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + abortController: new AbortController(), + })); + mockReleaseRunningFeature = vi.fn(); + + service = new RecoveryService( + mockEventBus as any, + mockConcurrencyManager as any, + mockSettingsService, + mockExecuteFeature, + mockLoadFeature, + mockDetectPipelineStatus, + mockResumePipeline, + mockIsFeatureRunning, + mockAcquireRunningFeature, + mockReleaseRunningFeature + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('DEFAULT_EXECUTION_STATE', () => { + it('has correct default values', () => { + expect(DEFAULT_EXECUTION_STATE).toEqual({ + version: 1, + autoLoopWasRunning: false, + maxConcurrency: expect.any(Number), + projectPath: '', + branchName: null, + runningFeatureIds: [], + savedAt: '', + }); + }); + }); + + describe('saveExecutionStateForProject', () => { + it('writes correct JSON to execution state path', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/test/project' }, + { featureId: 'feature-2', projectPath: '/test/project' }, + { featureId: 'feature-3', projectPath: '/other/project' }, + ]); + + await service.saveExecutionStateForProject('/test/project', 'feature-branch', 3); + + expect(secureFs.writeFile).toHaveBeenCalledWith( + '/test/project/.automaker/execution-state.json', + expect.any(String), + 'utf-8' + ); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent).toMatchObject({ + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 3, + projectPath: '/test/project', + branchName: 'feature-branch', + runningFeatureIds: ['feature-1', 'feature-2'], + }); + expect(writtenContent.savedAt).toBeDefined(); + }); + + it('filters running features by project path', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/project-a' }, + { featureId: 'feature-2', projectPath: '/project-b' }, + ]); + + await service.saveExecutionStateForProject('/project-a', null, 2); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent.runningFeatureIds).toEqual(['feature-1']); + }); + + it('handles null branch name for main worktree', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([]); + await service.saveExecutionStateForProject('/test/project', null, 1); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent.branchName).toBeNull(); + }); + }); + + describe('saveExecutionState (legacy)', () => { + it('saves execution state with legacy format', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/test' }, + ]); + + await service.saveExecutionState('/test/project', true, 5); + + expect(secureFs.writeFile).toHaveBeenCalled(); + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent).toMatchObject({ + autoLoopWasRunning: true, + maxConcurrency: 5, + branchName: null, // Legacy uses main worktree + }); + }); + }); + + describe('loadExecutionState', () => { + it('parses JSON correctly when file exists', async () => { + const mockState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 4, + projectPath: '/test/project', + branchName: 'dev', + runningFeatureIds: ['f1', 'f2'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(mockState)); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(mockState); + }); + + it('returns default state on ENOENT error', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValueOnce(error); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(DEFAULT_EXECUTION_STATE); + }); + + it('returns default state on other errors and logs', async () => { + vi.mocked(secureFs.readFile).mockRejectedValueOnce(new Error('Permission denied')); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(DEFAULT_EXECUTION_STATE); + }); + }); + + describe('clearExecutionState', () => { + it('removes execution state file', async () => { + await service.clearExecutionState('/test/project'); + + expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json'); + }); + + it('does not throw on ENOENT error', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.unlink).mockRejectedValueOnce(error); + + await expect(service.clearExecutionState('/test/project')).resolves.not.toThrow(); + }); + + it('logs error on other failures', async () => { + vi.mocked(secureFs.unlink).mockRejectedValueOnce(new Error('Permission denied')); + + await expect(service.clearExecutionState('/test/project')).resolves.not.toThrow(); + }); + }); + + describe('contextExists', () => { + it('returns true when agent-output.md exists', async () => { + vi.mocked(secureFs.access).mockResolvedValueOnce(undefined); + + const result = await service.contextExists('/test/project', 'feature-1'); + + expect(result).toBe(true); + expect(secureFs.access).toHaveBeenCalledWith( + normalizePath('/test/project/.automaker/features/feature-1/agent-output.md') + ); + }); + + it('returns false when agent-output.md is missing', async () => { + vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await service.contextExists('/test/project', 'feature-1'); + + expect(result).toBe(false); + }); + }); + + describe('resumeFeature', () => { + const mockFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + status: 'in_progress', + }; + + beforeEach(() => { + mockLoadFeature.mockResolvedValue(mockFeature); + }); + + it('skips if feature already running (idempotent)', async () => { + mockIsFeatureRunning.mockReturnValueOnce(true); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockLoadFeature).not.toHaveBeenCalled(); + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('detects pipeline status for feature', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockDetectPipelineStatus).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + }); + + it('delegates to resumePipeline for pipeline features', async () => { + const pipelineInfo = { + isPipeline: true, + stepId: 'test', + stepIndex: 1, + totalSteps: 3, + step: { + id: 'test', + name: 'Test Step', + command: 'npm test', + type: 'test' as const, + order: 1, + }, + config: null, + }; + mockDetectPipelineStatus.mockResolvedValueOnce(pipelineInfo); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockResumePipeline).toHaveBeenCalledWith( + '/test/project', + mockFeature, + false, + pipelineInfo + ); + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('calls executeFeature with continuation prompt when context exists', async () => { + // Reset settings-helpers mock before this test + const settingsHelpers = await import('@/lib/settings-helpers.js'); + vi.mocked(settingsHelpers.getPromptCustomization).mockResolvedValue({ + taskExecution: { + resumeFeatureTemplate: + 'Resume: {{featurePrompt}}\n\nPrevious context:\n{{previousContext}}', + implementationInstructions: '', + playwrightVerificationInstructions: '', + }, + } as any); + + vi.mocked(secureFs.access).mockResolvedValueOnce(undefined); + vi.mocked(secureFs.readFile).mockResolvedValueOnce('Previous agent output content'); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_resuming', + expect.objectContaining({ + featureId: 'feature-1', + hasContext: true, + }) + ); + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + false, + false, + undefined, + expect.objectContaining({ + continuationPrompt: expect.stringContaining('Previous agent output content'), + _calledInternally: true, + }) + ); + }); + + it('calls executeFeature fresh when no context', async () => { + vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('ENOENT')); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_resuming', + expect.objectContaining({ + featureId: 'feature-1', + hasContext: false, + }) + ); + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + false, + false, + undefined, + expect.objectContaining({ + _calledInternally: true, + }) + ); + }); + + it('releases running feature in finally block', async () => { + mockLoadFeature.mockRejectedValueOnce(new Error('Feature not found')); + + await expect(service.resumeFeature('/test/project', 'feature-1')).rejects.toThrow(); + + expect(mockReleaseRunningFeature).toHaveBeenCalledWith('feature-1'); + }); + + it('throws error if feature not found', async () => { + mockLoadFeature.mockResolvedValueOnce(null); + + await expect(service.resumeFeature('/test/project', 'feature-1')).rejects.toThrow( + 'Feature feature-1 not found' + ); + }); + + it('acquires running feature with allowReuse when calledInternally', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + await service.resumeFeature('/test/project', 'feature-1', false, true); + + expect(mockAcquireRunningFeature).toHaveBeenCalledWith({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: false, + allowReuse: true, + }); + }); + }); + + describe('resumeInterruptedFeatures', () => { + it('finds features with in_progress status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1'], + }) + ); + }); + + it('finds features with interrupted status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'interrupted' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'interrupted', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1'], + }) + ); + }); + + it('finds features with pipeline_* status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'pipeline_test' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'pipeline_test', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ id: 'feature-1', status: 'pipeline_test' }), + ]), + }) + ); + }); + + it('finds reconciled features using execution state (ready/backlog from previously running)', async () => { + // Simulate execution state with previously running feature IDs + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 2, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1', 'feature-2'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + { name: 'feature-3', isDirectory: () => true } as any, + ]); + // feature-1 was reconciled from in_progress to ready + // feature-2 was reconciled from in_progress to backlog + // feature-3 is in backlog but was NOT previously running + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-3', title: 'Feature 3', status: 'backlog' }, + wasRecovered: false, + }); + + mockLoadFeature + .mockResolvedValueOnce({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }) + .mockResolvedValueOnce({ + id: 'feature-2', + title: 'Feature 2', + status: 'backlog', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should resume feature-1 and feature-2 (from execution state) but NOT feature-3 + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1', 'feature-2'], + }) + ); + }); + + it('clears execution state after successful resume', async () => { + // Simulate execution state + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 1, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should clear execution state after resuming + expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json'); + }); + + it('distinguishes features with/without context', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-with', isDirectory: () => true } as any, + { name: 'feature-without', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-with', title: 'With Context', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-without', title: 'Without Context', status: 'in_progress' }, + wasRecovered: false, + }); + + // First feature has context, second doesn't + vi.mocked(secureFs.access) + .mockResolvedValueOnce(undefined) // feature-with has context + .mockRejectedValueOnce(new Error('ENOENT')); // feature-without doesn't + + mockLoadFeature + .mockResolvedValueOnce({ + id: 'feature-with', + title: 'With Context', + status: 'in_progress', + description: 'Test', + }) + .mockResolvedValueOnce({ + id: 'feature-without', + title: 'Without Context', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ id: 'feature-with', hasContext: true }), + expect.objectContaining({ id: 'feature-without', hasContext: false }), + ]), + }) + ); + }); + + it('emits auto_mode_resuming_features event', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + message: expect.stringContaining('interrupted feature'), + projectPath: '/test/project', + }) + ); + }); + + it('skips features already running (idempotent)', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }); + + mockIsFeatureRunning.mockReturnValue(true); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should emit event but not actually resume + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.anything() + ); + // But resumeFeature should exit early due to isFeatureRunning check + expect(mockLoadFeature).not.toHaveBeenCalled(); + }); + + it('handles ENOENT for features directory gracefully', async () => { + const error = new Error('Directory not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readdir).mockRejectedValueOnce(error); + + await expect(service.resumeInterruptedFeatures('/test/project')).resolves.not.toThrow(); + }); + + it('continues with other features when one fails', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-fail', isDirectory: () => true } as any, + { name: 'feature-success', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-fail', title: 'Fail', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-success', title: 'Success', status: 'in_progress' }, + wasRecovered: false, + }); + + // First feature throws during resume, second succeeds + mockLoadFeature.mockRejectedValueOnce(new Error('Resume failed')).mockResolvedValueOnce({ + id: 'feature-success', + title: 'Success', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should still attempt to resume the second feature + expect(mockLoadFeature).toHaveBeenCalledTimes(2); + }); + + it('logs info when no interrupted features found', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'completed' }, + wasRecovered: false, + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.anything() + ); + }); + }); +}); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index 70511af8..e54358fc 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -740,8 +740,11 @@ describe('settings-service.ts', () => { // Legacy fields should be migrated to phaseModels with canonical IDs expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' }); expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); - // Other fields should use defaults (canonical IDs) - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); + // Other fields should use defaults (canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'adaptive', + }); }); it('should use default phase models when none are configured', async () => { @@ -755,10 +758,13 @@ describe('settings-service.ts', () => { const settings = await settingsService.getGlobalSettings(); - // Should use DEFAULT_PHASE_MODELS (with canonical IDs) + // Should use DEFAULT_PHASE_MODELS (with canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); - expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'adaptive', + }); }); it('should deep merge phaseModels on update', async () => { diff --git a/apps/server/tests/unit/services/spec-parser.test.ts b/apps/server/tests/unit/services/spec-parser.test.ts new file mode 100644 index 00000000..e917622c --- /dev/null +++ b/apps/server/tests/unit/services/spec-parser.test.ts @@ -0,0 +1,641 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTasksFromSpec, + detectTaskStartMarker, + detectTaskCompleteMarker, + detectPhaseCompleteMarker, + detectSpecFallback, + extractSummary, +} from '../../../src/services/spec-parser.js'; + +describe('SpecParser', () => { + describe('parseTasksFromSpec', () => { + it('should parse tasks from a tasks code block', () => { + const specContent = ` +## Specification + +Some description here. + +\`\`\`tasks +- [ ] T001: Create user model | File: src/models/user.ts +- [ ] T002: Add API endpoint | File: src/routes/users.ts +- [ ] T003: Write unit tests | File: tests/user.test.ts +\`\`\` + +## Notes +Some notes here. +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0]).toEqual({ + id: 'T001', + description: 'Create user model', + filePath: 'src/models/user.ts', + phase: undefined, + status: 'pending', + }); + expect(tasks[1].id).toBe('T002'); + expect(tasks[2].id).toBe('T003'); + }); + + it('should parse tasks with phases', () => { + const specContent = ` +\`\`\`tasks +## Phase 1: Foundation +- [ ] T001: Initialize project | File: package.json +- [ ] T002: Configure TypeScript | File: tsconfig.json + +## Phase 2: Implementation +- [ ] T003: Create main module | File: src/index.ts +- [ ] T004: Add utility functions | File: src/utils.ts + +## Phase 3: Testing +- [ ] T005: Write tests | File: tests/index.test.ts +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(5); + expect(tasks[0].phase).toBe('Phase 1: Foundation'); + expect(tasks[1].phase).toBe('Phase 1: Foundation'); + expect(tasks[2].phase).toBe('Phase 2: Implementation'); + expect(tasks[3].phase).toBe('Phase 2: Implementation'); + expect(tasks[4].phase).toBe('Phase 3: Testing'); + }); + + it('should return empty array for content without tasks', () => { + const specContent = 'Just some text without any tasks'; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it('should fallback to finding task lines outside code block', () => { + const specContent = ` +## Implementation Plan + +- [ ] T001: First task | File: src/first.ts +- [ ] T002: Second task | File: src/second.ts +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T002'); + }); + + it('should handle empty tasks block', () => { + const specContent = ` +\`\`\`tasks +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it('should handle empty string input', () => { + const tasks = parseTasksFromSpec(''); + expect(tasks).toEqual([]); + }); + + it('should handle task without file path', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Task without file +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toEqual({ + id: 'T001', + description: 'Task without file', + phase: undefined, + status: 'pending', + }); + }); + + it('should handle mixed valid and invalid lines', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Valid task | File: src/valid.ts +- Invalid line +Some other text +- [ ] T002: Another valid task +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + }); + + it('should preserve task order', () => { + const specContent = ` +\`\`\`tasks +- [ ] T003: Third +- [ ] T001: First +- [ ] T002: Second +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks[0].id).toBe('T003'); + expect(tasks[1].id).toBe('T001'); + expect(tasks[2].id).toBe('T002'); + }); + + it('should handle task IDs with different numbers', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: First +- [ ] T010: Tenth +- [ ] T100: Hundredth +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T010'); + expect(tasks[2].id).toBe('T100'); + }); + + it('should trim whitespace from description and file path', () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Create API endpoint | File: src/routes/api.ts +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks[0].description).toBe('Create API endpoint'); + expect(tasks[0].filePath).toBe('src/routes/api.ts'); + }); + }); + + describe('detectTaskStartMarker', () => { + it('should detect task start marker and return task ID', () => { + expect(detectTaskStartMarker('[TASK_START] T001')).toBe('T001'); + expect(detectTaskStartMarker('[TASK_START] T042')).toBe('T042'); + expect(detectTaskStartMarker('[TASK_START] T999')).toBe('T999'); + }); + + it('should handle marker with description', () => { + expect(detectTaskStartMarker('[TASK_START] T001: Creating user model')).toBe('T001'); + }); + + it('should return null when no marker present', () => { + expect(detectTaskStartMarker('No marker here')).toBeNull(); + expect(detectTaskStartMarker('')).toBeNull(); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Some earlier output... + +Now starting the task: +[TASK_START] T003: Setting up database + +Let me begin by... +`; + expect(detectTaskStartMarker(accumulated)).toBe('T003'); + }); + + it('should handle whitespace variations', () => { + expect(detectTaskStartMarker('[TASK_START] T001')).toBe('T001'); + expect(detectTaskStartMarker('[TASK_START]\tT001')).toBe('T001'); + }); + + it('should not match invalid task IDs', () => { + expect(detectTaskStartMarker('[TASK_START] TASK1')).toBeNull(); + expect(detectTaskStartMarker('[TASK_START] T1')).toBeNull(); + expect(detectTaskStartMarker('[TASK_START] T12')).toBeNull(); + }); + }); + + describe('detectTaskCompleteMarker', () => { + it('should detect task complete marker and return task ID', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001'); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042'); + }); + + it('should handle marker with summary', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001'); + }); + + it('should return null when no marker present', () => { + expect(detectTaskCompleteMarker('No marker here')).toBeNull(); + expect(detectTaskCompleteMarker('')).toBeNull(); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Working on the task... + +Done with the implementation: +[TASK_COMPLETE] T003: Database setup complete + +Moving on to... +`; + expect(detectTaskCompleteMarker(accumulated)).toBe('T003'); + }); + + it('should not confuse with TASK_START marker', () => { + expect(detectTaskCompleteMarker('[TASK_START] T001')).toBeNull(); + }); + + it('should not match invalid task IDs', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull(); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull(); + }); + }); + + describe('detectPhaseCompleteMarker', () => { + it('should detect phase complete marker and return phase number', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2')).toBe(2); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 10')).toBe(10); + }); + + it('should handle marker with description', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1 complete')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2: Foundation done')).toBe(2); + }); + + it('should return null when no marker present', () => { + expect(detectPhaseCompleteMarker('No marker here')).toBeNull(); + expect(detectPhaseCompleteMarker('')).toBeNull(); + }); + + it('should be case-insensitive', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] PHASE 2')).toBe(2); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Finishing up the phase... + +All tasks complete: +[PHASE_COMPLETE] Phase 2 complete + +Starting Phase 3... +`; + expect(detectPhaseCompleteMarker(accumulated)).toBe(2); + }); + + it('should not confuse with task markers', () => { + expect(detectPhaseCompleteMarker('[TASK_COMPLETE] T001')).toBeNull(); + }); + }); + + describe('detectSpecFallback', () => { + it('should detect spec with tasks block and acceptance criteria', () => { + const content = ` +## Acceptance Criteria +- GIVEN a user, WHEN they login, THEN they see the dashboard + +\`\`\`tasks +- [ ] T001: Create login form | File: src/Login.tsx +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with task lines and problem statement', () => { + const content = ` +## Problem Statement +Users cannot currently log in to the application. + +## Implementation Plan +- [ ] T001: Add authentication endpoint +- [ ] T002: Create login UI +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Goal section (lite planning mode)', () => { + const content = ` +**Goal**: Implement user authentication + +**Solution**: Use JWT tokens for session management + +- [ ] T001: Setup auth middleware +- [ ] T002: Create token service +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with User Story format', () => { + const content = ` +## User Story +As a user, I want to reset my password, so that I can regain access. + +## Technical Context +This will modify the auth module. + +\`\`\`tasks +- [ ] T001: Add reset endpoint +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Overview section', () => { + const content = ` +## Overview +This feature adds dark mode support. + +\`\`\`tasks +- [ ] T001: Add theme toggle +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Summary section', () => { + const content = ` +## Summary +Adding a new dashboard component. + +- [ ] T001: Create dashboard layout +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation plan', () => { + const content = ` +## Implementation Plan +We will add the feature in two phases. + +- [ ] T001: Phase 1 setup +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation steps', () => { + const content = ` +## Implementation Steps +Follow these steps: + +- [ ] T001: Step one +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation approach', () => { + const content = ` +## Implementation Approach +We will use a modular approach. + +- [ ] T001: Create modules +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should NOT detect spec without task structure', () => { + const content = ` +## Problem Statement +Users cannot log in. + +## Acceptance Criteria +- GIVEN a user, WHEN they try to login, THEN it works +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect spec without spec content sections', () => { + const content = ` +Here are some tasks: + +- [ ] T001: Do something +- [ ] T002: Do another thing +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect random text as spec', () => { + expect(detectSpecFallback('Just some random text')).toBe(false); + expect(detectSpecFallback('')).toBe(false); + }); + + it('should handle case-insensitive matching for spec sections', () => { + const content = ` +## ACCEPTANCE CRITERIA +All caps section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content)).toBe(true); + }); + }); + + describe('extractSummary', () => { + describe('explicit tags', () => { + it('should extract content from summary tags', () => { + const text = 'Some preamble This is the summary content more text'; + expect(extractSummary(text)).toBe('This is the summary content'); + }); + + it('should use last match to avoid stale summaries', () => { + const text = ` +Old stale summary + +More agent output... + +Fresh new summary +`; + expect(extractSummary(text)).toBe('Fresh new summary'); + }); + + it('should handle multiline summary content', () => { + const text = `First line +Second line +Third line`; + expect(extractSummary(text)).toBe('First line\nSecond line\nThird line'); + }); + + it('should trim whitespace from summary', () => { + const text = ' trimmed content '; + expect(extractSummary(text)).toBe('trimmed content'); + }); + }); + + describe('## Summary section (markdown)', () => { + it('should extract from ## Summary section', () => { + const text = ` +## Summary + +This is a summary paragraph. + +## Other Section +More content. +`; + expect(extractSummary(text)).toBe('This is a summary paragraph.'); + }); + + it('should truncate long summaries to 500 chars', () => { + const longContent = 'A'.repeat(600); + const text = ` +## Summary + +${longContent} + +## Next Section +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); // 500 + '...' + expect(result!.endsWith('...')).toBe(true); + }); + + it('should use last match for ## Summary', () => { + const text = ` +## Summary + +Old summary content. + +## Summary + +New summary content. +`; + expect(extractSummary(text)).toBe('New summary content.'); + }); + + it('should stop at next markdown header', () => { + const text = ` +## Summary + +Summary content here. + +## Implementation +Implementation details. +`; + expect(extractSummary(text)).toBe('Summary content here.'); + }); + }); + + describe('**Goal**: section (lite planning mode)', () => { + it('should extract from **Goal**: section', () => { + const text = '**Goal**: Implement user authentication\n**Approach**: Use JWT'; + expect(extractSummary(text)).toBe('Implement user authentication'); + }); + + it('should use last match for **Goal**:', () => { + const text = ` +**Goal**: Old goal + +More output... + +**Goal**: New goal +`; + expect(extractSummary(text)).toBe('New goal'); + }); + + it('should handle inline goal', () => { + const text = '1. **Goal**: Add login functionality'; + expect(extractSummary(text)).toBe('Add login functionality'); + }); + }); + + describe('**Problem**: section (spec/full modes)', () => { + it('should extract from **Problem**: section', () => { + const text = ` +**Problem**: Users cannot log in to the application + +**Solution**: Add authentication +`; + expect(extractSummary(text)).toBe('Users cannot log in to the application'); + }); + + it('should extract from **Problem Statement**: section', () => { + const text = ` +**Problem Statement**: Users need password reset functionality + +1. Create reset endpoint +`; + expect(extractSummary(text)).toBe('Users need password reset functionality'); + }); + + it('should truncate long problem descriptions', () => { + const longProblem = 'X'.repeat(600); + const text = `**Problem**: ${longProblem}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); + }); + }); + + describe('**Solution**: section (fallback)', () => { + it('should extract from **Solution**: section as fallback', () => { + const text = '**Solution**: Use JWT for authentication\n1. Install package'; + expect(extractSummary(text)).toBe('Use JWT for authentication'); + }); + + it('should truncate solution to 300 chars', () => { + const longSolution = 'Y'.repeat(400); + const text = `**Solution**: ${longSolution}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(303); + }); + }); + + describe('priority order', () => { + it('should prefer over ## Summary', () => { + const text = ` +## Summary + +Markdown summary + +Tagged summary +`; + expect(extractSummary(text)).toBe('Tagged summary'); + }); + + it('should prefer ## Summary over **Goal**:', () => { + const text = ` +**Goal**: Goal content + +## Summary + +Summary section content. +`; + expect(extractSummary(text)).toBe('Summary section content.'); + }); + + it('should prefer **Goal**: over **Problem**:', () => { + const text = ` +**Problem**: Problem description + +**Goal**: Goal description +`; + expect(extractSummary(text)).toBe('Goal description'); + }); + + it('should prefer **Problem**: over **Solution**:', () => { + const text = ` +**Solution**: Solution description + +**Problem**: Problem description +`; + expect(extractSummary(text)).toBe('Problem description'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(extractSummary('')).toBeNull(); + }); + + it('should return null when no summary pattern found', () => { + expect(extractSummary('Random text without any summary patterns')).toBeNull(); + }); + + it('should handle multiple paragraph summaries (return first paragraph)', () => { + const text = ` +## Summary + +First paragraph of summary. + +Second paragraph of summary. + +## Other +`; + expect(extractSummary(text)).toBe('First paragraph of summary.'); + }); + }); + }); +}); diff --git a/apps/server/tests/unit/services/typed-event-bus.test.ts b/apps/server/tests/unit/services/typed-event-bus.test.ts new file mode 100644 index 00000000..85c5b202 --- /dev/null +++ b/apps/server/tests/unit/services/typed-event-bus.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js'; + +/** + * Create a mock EventEmitter for testing + */ +function createMockEventEmitter(): EventEmitter & { + emitCalls: Array<{ type: EventType; payload: unknown }>; + subscribers: Set; +} { + const subscribers = new Set(); + const emitCalls: Array<{ type: EventType; payload: unknown }> = []; + + return { + emitCalls, + subscribers, + emit(type: EventType, payload: unknown) { + emitCalls.push({ type, payload }); + // Also call subscribers to simulate real behavior + for (const callback of subscribers) { + callback(type, payload); + } + }, + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; +} + +describe('TypedEventBus', () => { + let mockEmitter: ReturnType; + let eventBus: TypedEventBus; + + beforeEach(() => { + mockEmitter = createMockEventEmitter(); + eventBus = new TypedEventBus(mockEmitter); + }); + + describe('constructor', () => { + it('should wrap an EventEmitter', () => { + expect(eventBus).toBeInstanceOf(TypedEventBus); + }); + + it('should store the underlying emitter', () => { + expect(eventBus.getUnderlyingEmitter()).toBe(mockEmitter); + }); + }); + + describe('emit', () => { + it('should pass events directly to the underlying emitter', () => { + const payload = { test: 'data' }; + eventBus.emit('feature:created', payload); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'feature:created', + payload: { test: 'data' }, + }); + }); + + it('should handle various event types', () => { + eventBus.emit('feature:updated', { id: '1' }); + eventBus.emit('agent:streaming', { chunk: 'data' }); + eventBus.emit('error', { message: 'error' }); + + expect(mockEmitter.emitCalls).toHaveLength(3); + expect(mockEmitter.emitCalls[0].type).toBe('feature:updated'); + expect(mockEmitter.emitCalls[1].type).toBe('agent:streaming'); + expect(mockEmitter.emitCalls[2].type).toBe('error'); + }); + }); + + describe('emitAutoModeEvent', () => { + it('should wrap events in auto-mode:event format', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('auto-mode:event'); + }); + + it('should include event type in payload', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_started'); + }); + + it('should spread additional data into payload', () => { + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ + type: 'auto_mode_feature_start', + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + }); + + it('should handle empty data object', () => { + eventBus.emitAutoModeEvent('auto_mode_idle', {}); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ type: 'auto_mode_idle' }); + }); + + it('should preserve exact event format for frontend compatibility', () => { + // This test verifies the exact format that the frontend expects + eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }); + + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_progress', + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }, + }); + }); + + it('should handle all standard auto-mode event types', () => { + const eventTypes = [ + 'auto_mode_started', + 'auto_mode_stopped', + 'auto_mode_idle', + 'auto_mode_error', + 'auto_mode_paused_failures', + 'auto_mode_feature_start', + 'auto_mode_feature_complete', + 'auto_mode_feature_resuming', + 'auto_mode_progress', + 'auto_mode_tool', + 'auto_mode_task_started', + 'auto_mode_task_complete', + 'planning_started', + 'plan_approval_required', + 'plan_approved', + 'plan_rejected', + ] as const; + + for (const eventType of eventTypes) { + eventBus.emitAutoModeEvent(eventType, { test: true }); + } + + expect(mockEmitter.emitCalls).toHaveLength(eventTypes.length); + mockEmitter.emitCalls.forEach((call, index) => { + expect(call.type).toBe('auto-mode:event'); + const payload = call.payload as Record; + expect(payload.type).toBe(eventTypes[index]); + }); + }); + + it('should allow custom event types (string extensibility)', () => { + eventBus.emitAutoModeEvent('custom_event_type', { custom: 'data' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('custom_event_type'); + }); + }); + + describe('subscribe', () => { + it('should pass subscriptions to the underlying emitter', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + + unsubscribe(); + + expect(mockEmitter.subscribers.has(callback)).toBe(false); + }); + + it('should receive events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emit('feature:created', { id: '1' }); + + expect(callback).toHaveBeenCalledWith('feature:created', { id: '1' }); + }); + + it('should receive auto-mode events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(callback).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_started', + projectPath: '/test', + }); + }); + + it('should not receive events after unsubscribe', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emit('event1', {}); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + + eventBus.emit('event2', {}); + expect(callback).toHaveBeenCalledTimes(1); // Still 1, not called again + }); + }); + + describe('getUnderlyingEmitter', () => { + it('should return the wrapped EventEmitter', () => { + const emitter = eventBus.getUnderlyingEmitter(); + expect(emitter).toBe(mockEmitter); + }); + + it('should allow direct access for special cases', () => { + const emitter = eventBus.getUnderlyingEmitter(); + + // Verify we can use it directly + emitter.emit('direct:event', { direct: true }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('direct:event'); + }); + }); + + describe('integration with real EventEmitter pattern', () => { + it('should produce the exact payload format used by AutoModeService', () => { + // This test documents the exact format that was in AutoModeService.emitAutoModeEvent + // before extraction, ensuring backward compatibility + + const receivedEvents: Array<{ type: EventType; payload: unknown }> = []; + + eventBus.subscribe((type, payload) => { + receivedEvents.push({ type, payload }); + }); + + // Simulate the exact call pattern from AutoModeService + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }); + + expect(receivedEvents).toHaveLength(1); + expect(receivedEvents[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_feature_start', + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }, + }); + }); + + it('should handle complex nested data in events', () => { + eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId: 'feat-1', + tool: { + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }, + timestamp: 1234567890, + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_tool'); + expect(payload.tool).toEqual({ + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }); + }); + }); +}); diff --git a/apps/server/tests/unit/services/worktree-resolver.test.ts b/apps/server/tests/unit/services/worktree-resolver.test.ts new file mode 100644 index 00000000..43d49edc --- /dev/null +++ b/apps/server/tests/unit/services/worktree-resolver.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { WorktreeResolver, type WorktreeInfo } from '@/services/worktree-resolver.js'; +import { exec } from 'child_process'; +import path from 'path'; + +// Mock child_process +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +/** + * Helper to normalize paths for cross-platform test compatibility. + * On Windows, path.resolve('/Users/dev/project') returns 'C:\Users\dev\project' (with current drive). + * This helper ensures test expectations match the actual platform behavior. + */ +const normalizePath = (p: string): string => path.resolve(p); + +// Create promisified mock helper +const mockExecAsync = ( + impl: (cmd: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> +) => { + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + options: { cwd?: string } | undefined, + callback: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + impl(cmd, options) + .then((result) => callback(null, result)) + .catch((error) => callback(error, { stdout: '', stderr: '' })); + } + ); +}; + +describe('WorktreeResolver', () => { + let resolver: WorktreeResolver; + + beforeEach(() => { + vi.clearAllMocks(); + resolver = new WorktreeResolver(); + }); + + describe('getCurrentBranch', () => { + it('should return branch name when on a branch', async () => { + mockExecAsync(async () => ({ stdout: 'main\n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('main'); + }); + + it('should return null on detached HEAD (empty output)', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const branch = await resolver.getCurrentBranch('/not/a/git/repo'); + + expect(branch).toBeNull(); + }); + + it('should trim whitespace from branch name', async () => { + mockExecAsync(async () => ({ stdout: ' feature-branch \n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('feature-branch'); + }); + + it('should use provided projectPath as cwd', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: 'main\n', stderr: '' }; + }); + + await resolver.getCurrentBranch('/custom/path'); + + expect(capturedCwd).toBe('/custom/path'); + }); + }); + + describe('findWorktreeForBranch', () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + it('should find worktree by branch name', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should return null when branch not found', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'non-existent'); + + expect(path).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const path = await resolver.findWorktreeForBranch('/not/a/repo', 'main'); + + expect(path).toBeNull(); + }); + + it('should find main worktree', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'main'); + + expect(result).toBe(normalizePath('/Users/dev/project')); + }); + + it('should handle porcelain output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/feature-relative +branch refs/heads/feature-relative +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-relative'); + + // Should resolve to absolute path (platform-specific) + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-relative')); + }); + + it('should use projectPath as cwd for git command', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: porcelainOutput, stderr: '' }; + }); + + await resolver.findWorktreeForBranch('/custom/project', 'main'); + + expect(capturedCwd).toBe('/custom/project'); + }); + }); + + describe('listWorktrees', () => { + it('should list all worktrees with metadata', async () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(3); + expect(worktrees[0]).toEqual({ + path: normalizePath('/Users/dev/project'), + branch: 'main', + isMain: true, + }); + expect(worktrees[1]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/feature-x'), + branch: 'feature-x', + isMain: false, + }); + expect(worktrees[2]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/feature-y'), + branch: 'feature-y', + isMain: false, + }); + }); + + it('should return empty array when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const worktrees = await resolver.listWorktrees('/not/a/repo'); + + expect(worktrees).toEqual([]); + }); + + it('should handle detached HEAD worktrees', async () => { + const porcelainWithDetached = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/detached-wt +detached +`; + + mockExecAsync(async () => ({ stdout: porcelainWithDetached, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/detached-wt'), + branch: null, // Detached HEAD has no branch + isMain: false, + }); + }); + + it('should mark only first worktree as main', async () => { + const multipleWorktrees = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/wt1 +branch refs/heads/branch1 + +worktree /Users/dev/project/.worktrees/wt2 +branch refs/heads/branch2 +`; + + mockExecAsync(async () => ({ stdout: multipleWorktrees, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[0].isMain).toBe(true); + expect(worktrees[1].isMain).toBe(false); + expect(worktrees[2].isMain).toBe(false); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/relative-wt +branch refs/heads/relative-branch +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[1].path).toBe(normalizePath('/Users/dev/project/.worktrees/relative-wt')); + }); + + it('should handle single worktree (main only)', async () => { + const singleWorktree = `worktree /Users/dev/project +branch refs/heads/main +`; + + mockExecAsync(async () => ({ stdout: singleWorktree, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(1); + expect(worktrees[0]).toEqual({ + path: normalizePath('/Users/dev/project'), + branch: 'main', + isMain: true, + }); + }); + + it('should handle empty git worktree list output', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toEqual([]); + }); + + it('should handle output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1].branch).toBe('feature-x'); + }); + }); +}); diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md index 4c9f0d11..f5c374c4 100644 --- a/apps/ui/docs/AGENT_ARCHITECTURE.md +++ b/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -199,7 +199,7 @@ The agent is configured with: ```javascript { - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", maxTurns: 20, cwd: workingDirectory, allowedTools: [ diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 6cf025de..0bd5a9b2 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import js from '@eslint/js'; import ts from '@typescript-eslint/eslint-plugin'; import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; const eslintConfig = defineConfig([ js.configs.recommended, @@ -51,6 +52,8 @@ const eslintConfig = defineConfig([ getComputedStyle: 'readonly', requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly', + requestIdleCallback: 'readonly', + cancelIdleCallback: 'readonly', alert: 'readonly', // DOM Element Types HTMLElement: 'readonly', @@ -62,6 +65,8 @@ const eslintConfig = defineConfig([ HTMLHeadingElement: 'readonly', HTMLParagraphElement: 'readonly', HTMLImageElement: 'readonly', + HTMLLinkElement: 'readonly', + HTMLScriptElement: 'readonly', Element: 'readonly', SVGElement: 'readonly', SVGSVGElement: 'readonly', @@ -76,6 +81,7 @@ const eslintConfig = defineConfig([ MouseEvent: 'readonly', UIEvent: 'readonly', MediaQueryListEvent: 'readonly', + PageTransitionEvent: 'readonly', DataTransfer: 'readonly', // Web APIs ResizeObserver: 'readonly', @@ -91,11 +97,13 @@ const eslintConfig = defineConfig([ Response: 'readonly', RequestInit: 'readonly', RequestCache: 'readonly', + ServiceWorkerRegistration: 'readonly', // Timers setTimeout: 'readonly', setInterval: 'readonly', clearTimeout: 'readonly', clearInterval: 'readonly', + queueMicrotask: 'readonly', // Node.js (for scripts and Electron) process: 'readonly', require: 'readonly', @@ -109,16 +117,30 @@ const eslintConfig = defineConfig([ Electron: 'readonly', // Console console: 'readonly', + // Structured clone (modern browser/Node API) + structuredClone: 'readonly', // Vite defines __APP_VERSION__: 'readonly', + __APP_BUILD_HASH__: 'readonly', }, }, plugins: { '@typescript-eslint': ts, + 'react-hooks': reactHooks, }, rules: { ...ts.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/ban-ts-comment': [ 'error', @@ -129,6 +151,32 @@ const eslintConfig = defineConfig([ ], }, }, + { + files: ['public/sw.js'], + languageOptions: { + globals: { + // Service Worker globals + self: 'readonly', + caches: 'readonly', + fetch: 'readonly', + Headers: 'readonly', + Response: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + console: 'readonly', + // Built-in globals used in sw.js + Date: 'readonly', + Promise: 'readonly', + Set: 'readonly', + JSON: 'readonly', + String: 'readonly', + Array: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + }, + }, globalIgnores([ 'dist/**', 'dist-electron/**', diff --git a/apps/ui/index.html b/apps/ui/index.html index 49a7aa1e..afc0f07f 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -4,32 +4,237 @@ Automaker - Autonomous AI Development Studio - + + + + + + + + + + + + + + + -
+
+ +
+ +
+
+
diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index 2d96d158..da56165d 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -1,9 +1,30 @@ +# Map for conditional WebSocket upgrade header +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; + # Proxy API and WebSocket requests to the backend server container + # Handles both HTTP API calls and WebSocket upgrades (/api/events, /api/terminal/ws) + location /api/ { + proxy_pass http://server:3008; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/ui/package.json b/apps/ui/package.json index 2a9b71b2..7b2c35f1 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.13.0", + "version": "0.15.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -42,10 +42,25 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/spec-parser": "1.0.0", "@automaker/types": "1.0.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "6.1.0", "@codemirror/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/merge": "^6.12.0", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "^6.39.15", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", @@ -82,6 +97,7 @@ "@radix-ui/react-tooltip": "1.2.8", "@tanstack/react-query": "^5.90.17", "@tanstack/react-query-devtools": "^5.91.2", + "@tanstack/react-query-persist-client": "^5.90.22", "@tanstack/react-router": "1.141.6", "@uiw/react-codemirror": "4.25.4", "@xterm/addon-fit": "0.10.0", @@ -96,6 +112,7 @@ "dagre": "0.8.5", "dotenv": "17.2.3", "geist": "1.5.1", + "idb-keyval": "^6.2.2", "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", @@ -138,6 +155,7 @@ "electron": "39.2.7", "electron-builder": "26.0.12", "eslint": "9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", "tailwindcss": "4.1.18", "tw-animate-css": "1.4.0", "typescript": "5.9.3", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index f301fa30..5a56289f 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; -const port = process.env.TEST_PORT || 3007; -const serverPort = process.env.TEST_SERVER_PORT || 3008; +const port = process.env.TEST_PORT || 3107; +const serverPort = process.env.TEST_SERVER_PORT || 3108; const reuseServer = process.env.TEST_REUSE_SERVER === 'true'; const useExternalBackend = !!process.env.VITE_SERVER_URL; // Always use mock agent for tests (disables rate limiting, uses mock Claude responses) @@ -19,6 +19,7 @@ export default defineConfig({ baseURL: `http://localhost:${port}`, trace: 'on-failure', screenshot: 'only-on-failure', + serviceWorkers: 'block', }, // Global setup - authenticate before each test globalSetup: require.resolve('./tests/global-setup.ts'), @@ -69,6 +70,10 @@ export default defineConfig({ timeout: 120000, env: { ...process.env, + // Must set AUTOMAKER_WEB_PORT to match the port Playwright waits for + AUTOMAKER_WEB_PORT: String(port), + // Must set AUTOMAKER_SERVER_PORT so Vite proxy forwards to the correct backend port + AUTOMAKER_SERVER_PORT: String(serverPort), VITE_SKIP_SETUP: 'true', // Always skip electron plugin during tests - prevents duplicate server spawning VITE_SKIP_ELECTRON: 'true', diff --git a/apps/ui/public/manifest.json b/apps/ui/public/manifest.json new file mode 100644 index 00000000..5231d721 --- /dev/null +++ b/apps/ui/public/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "Automaker - Autonomous AI Development Studio", + "short_name": "Automaker", + "description": "Build software autonomously with AI agents", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "orientation": "any", + "scope": "/", + "id": "/", + "icons": [ + { + "src": "/logo.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/logo_larger.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/automaker.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "categories": ["developer", "productivity", "utilities"], + "lang": "en-US", + "dir": "ltr", + "launch_handler": { + "client_mode": "focus-existing" + }, + "handle_links": "preferred", + "edge_side_panel": { + "preferred_width": 480 + }, + "prefer_related_applications": false, + "display_override": ["standalone", "minimal-ui"] +} diff --git a/apps/ui/public/sw.js b/apps/ui/public/sw.js new file mode 100644 index 00000000..1370cb7c --- /dev/null +++ b/apps/ui/public/sw.js @@ -0,0 +1,626 @@ +// Automaker Service Worker - Optimized for mobile PWA loading performance +// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster +// Vite plugin (see vite.config.mts). In development it stays as-is; in production +// builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation. +const CACHE_NAME = 'automaker-v5'; // replaced at build time → 'automaker-v5-' + +// Separate cache for immutable hashed assets (long-lived) +const IMMUTABLE_CACHE = 'automaker-immutable-v2'; + +// Separate cache for API responses (short-lived, stale-while-revalidate on mobile) +const API_CACHE = 'automaker-api-v1'; + +// Assets to cache on install (app shell for instant loading) +const SHELL_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/logo.png', + '/logo_larger.png', + '/automaker.svg', + '/favicon.ico', +]; + +// Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster +// Vite plugin. Populated during production builds; empty in dev mode. +// These are precached on SW install so that PWA cold starts after memory eviction +// serve instantly from cache instead of requiring a full network download. +const CRITICAL_ASSETS = []; + +// Whether mobile caching is enabled (set via message from main thread). +// Persisted to Cache Storage so it survives aggressive SW termination on mobile. +let mobileMode = false; +const MOBILE_MODE_CACHE_KEY = 'automaker-sw-config'; +const MOBILE_MODE_URL = '/sw-config/mobile-mode'; + +/** + * Persist mobileMode to Cache Storage so it survives SW restarts. + * Service workers on mobile get killed aggressively — without persistence, + * mobileMode resets to false and API caching silently stops working. + */ +async function persistMobileMode(enabled) { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = new Response(JSON.stringify({ mobileMode: enabled }), { + headers: { 'Content-Type': 'application/json' }, + }); + await cache.put(MOBILE_MODE_URL, response); + } catch (_e) { + // Best-effort persistence — SW still works without it + } +} + +/** + * Restore mobileMode from Cache Storage on SW startup. + */ +async function restoreMobileMode() { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = await cache.match(MOBILE_MODE_URL); + if (response) { + const data = await response.json(); + mobileMode = !!data.mobileMode; + } + } catch (_e) { + // Best-effort restore — defaults to false + } +} + +// Restore mobileMode immediately on SW startup +// Keep a promise so fetch handlers can await restoration on cold SW starts. +// This prevents a race where early API requests run before mobileMode is loaded +// from Cache Storage, incorrectly falling back to network-first. +const mobileModeRestorePromise = restoreMobileMode(); + +// API endpoints that are safe to serve from stale cache on mobile. +// These are GET-only, read-heavy endpoints where showing slightly stale data +// is far better than a blank screen or reload on flaky mobile connections. +const CACHEABLE_API_PATTERNS = [ + '/api/features', + '/api/settings', + '/api/models', + '/api/usage', + '/api/worktrees', + '/api/github', + '/api/cli', + '/api/sessions', + '/api/running-agents', + '/api/pipeline', + '/api/workspace', + '/api/spec', +]; + +// Max age for API cache entries (5 minutes). +// After this, even mobile will require a network fetch. +const API_CACHE_MAX_AGE = 5 * 60 * 1000; + +// Maximum entries in API cache to prevent unbounded growth +const API_CACHE_MAX_ENTRIES = 100; + +/** + * Check if an API request is safe to cache (read-only data endpoints) + */ +function isCacheableApiRequest(url) { + const path = url.pathname; + if (!path.startsWith('/api/')) return false; + return CACHEABLE_API_PATTERNS.some((pattern) => path.startsWith(pattern)); +} + +/** + * Check if a cached API response is still fresh enough to use + */ +function isApiCacheFresh(response) { + const cachedAt = response.headers.get('x-sw-cached-at'); + if (!cachedAt) return false; + return Date.now() - parseInt(cachedAt, 10) < API_CACHE_MAX_AGE; +} + +/** + * Clone a response and add a timestamp header for cache freshness tracking. + * Uses arrayBuffer() instead of blob() to avoid doubling memory for large responses. + */ +async function addCacheTimestamp(response) { + const headers = new Headers(response.headers); + headers.set('x-sw-cached-at', String(Date.now())); + const body = await response.clone().arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +self.addEventListener('install', (event) => { + // Cache the app shell AND critical JS/CSS assets so the PWA loads instantly. + // SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into + // IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even + // the very first visit populates the immutable cache — previously, assets were + // only cached on fetch interception, but the SW isn't active during the first + // page load so nothing got cached until the second visit. + // + // self.skipWaiting() is NOT called here — activation is deferred until the main + // thread sends a SKIP_WAITING message to avoid disrupting a live page. + event.waitUntil( + Promise.all([ + // Cache app shell (HTML, icons, manifest) using individual fetch+put instead of + // cache.addAll(). This is critical because cache.addAll() respects the server's + // Cache-Control response headers — if the server sends 'Cache-Control: no-store' + // (which Vite dev server does for index.html), addAll() silently skips caching + // and the pre-React loading spinner is never served from cache. + // + // cache.put() bypasses Cache-Control headers entirely, ensuring the app shell + // is always cached on install regardless of what the server sends. This is the + // correct approach for SW-managed caches where the SW (not HTTP headers) controls + // freshness via the activate event's cache cleanup and the navigation strategy's + // background revalidation. + caches.open(CACHE_NAME).then((cache) => + Promise.all( + SHELL_ASSETS.map((url) => + fetch(url) + .then((response) => { + if (response.ok) return cache.put(url, response); + }) + .catch(() => { + // Individual asset fetch failure is non-fatal — the SW still activates + // and the next navigation will populate the cache via Strategy 3. + }) + ) + ) + ), + // Cache critical JS/CSS bundles (injected at build time by swCacheBuster). + // Uses individual fetch+put instead of cache.addAll() so a single asset + // failure doesn't prevent the rest from being cached. + // + // IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses + //