mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Compare commits
15 Commits
5a5c56a4cf
...
v0.14.0rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88864ad6bc | ||
|
|
ebc7987988 | ||
|
|
29b3eef500 | ||
|
|
010e516b0e | ||
|
|
00e4712ae7 | ||
|
|
4b4ae04fbe | ||
|
|
04775af561 | ||
|
|
b8fa7fc579 | ||
|
|
7fb0d0f2ca | ||
|
|
f15725f28a | ||
|
|
7d7d152d4e | ||
|
|
07f777da22 | ||
|
|
b10501ea79 | ||
|
|
1a460c301a | ||
|
|
c1f480fe49 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -95,6 +95,3 @@ data/.api-key
|
||||
data/credentials.json
|
||||
data/
|
||||
.codex/
|
||||
|
||||
# GSD planning docs (local-only)
|
||||
.planning/
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# AutoModeService Refactoring
|
||||
|
||||
## What This Is
|
||||
|
||||
A comprehensive refactoring of the `auto-mode-service.ts` file (5k+ lines) into smaller, focused services with clear boundaries. This is an architectural cleanup of accumulated technical debt from rapid development, breaking the "god object" anti-pattern into maintainable, debuggable modules.
|
||||
|
||||
## Core Value
|
||||
|
||||
All existing auto-mode functionality continues working — features execute, pipelines flow, merges complete — while the codebase becomes maintainable.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- Existing functionality that must be preserved -->
|
||||
|
||||
- ✓ Single feature execution with AI agent — existing
|
||||
- ✓ Concurrent execution with configurable limits — existing
|
||||
- ✓ Pipeline orchestration (backlog → in-progress → approval → verified) — existing
|
||||
- ✓ Git worktree isolation per feature — existing
|
||||
- ✓ Automatic merging of completed work — existing
|
||||
- ✓ Custom pipeline support — existing
|
||||
- ✓ Test runner integration — existing
|
||||
- ✓ Event streaming to frontend — existing
|
||||
|
||||
### Active
|
||||
|
||||
<!-- Refactoring goals -->
|
||||
|
||||
- [ ] No service file exceeds ~500 lines
|
||||
- [ ] Each service has single, clear responsibility
|
||||
- [ ] Service boundaries make debugging obvious
|
||||
- [ ] Changes to one service don't risk breaking unrelated features
|
||||
- [ ] Test coverage for critical paths
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- New auto-mode features — this is cleanup, not enhancement
|
||||
- UI changes — backend refactor only
|
||||
- Performance optimization — maintain current performance, don't optimize
|
||||
- Other service refactoring — focus on auto-mode-service.ts only
|
||||
|
||||
## Context
|
||||
|
||||
**Current state:** `apps/server/src/services/auto-mode-service.ts` is ~5700 lines handling:
|
||||
|
||||
- Worktree management (create, cleanup, track)
|
||||
- Agent/task execution coordination
|
||||
- Concurrency control and queue management
|
||||
- Pipeline state machine (column transitions)
|
||||
- Merge handling and conflict resolution
|
||||
- Event emission for real-time updates
|
||||
|
||||
**Technical environment:**
|
||||
|
||||
- Express 5 backend, TypeScript
|
||||
- Event-driven architecture via EventEmitter
|
||||
- WebSocket streaming to React frontend
|
||||
- Git worktrees via @automaker/git-utils
|
||||
- Minimal existing test coverage
|
||||
|
||||
**Codebase analysis:** See `.planning/codebase/` for full architecture, conventions, and existing patterns.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Breaking changes**: Acceptable — other parts of the app can be updated to match new service interfaces
|
||||
- **Test coverage**: Currently minimal — must add tests during refactoring to catch regressions
|
||||
- **Incremental approach**: Required — can't do big-bang rewrite with everything critical
|
||||
- **Existing patterns**: Follow conventions in `.planning/codebase/CONVENTIONS.md`
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
| ------------------------- | --------------------------------------------------- | --------- |
|
||||
| Accept breaking changes | Allows cleaner interfaces, worth the migration cost | — Pending |
|
||||
| Add tests during refactor | No existing safety net, need to build one | — Pending |
|
||||
| Incremental extraction | Everything is critical, can't break it all at once | — Pending |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-01-27 after initialization_
|
||||
@@ -1,234 +0,0 @@
|
||||
# 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_
|
||||
@@ -1,245 +0,0 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Loose Type Safety in Error Handling:**
|
||||
|
||||
- Issue: Multiple uses of `as any` type assertions bypass TypeScript safety, particularly in error context handling and provider responses
|
||||
- Files: `apps/server/src/providers/claude-provider.ts` (lines 318-322), `apps/server/src/lib/error-handler.ts`, `apps/server/src/routes/settings/routes/update-global.ts`
|
||||
- Impact: Errors could have unchecked properties; refactoring becomes risky without compiler assistance
|
||||
- Fix approach: Replace `as any` with proper type guards and discriminated unions; create helper functions for safe property access
|
||||
|
||||
**Missing Test Coverage for Critical Services:**
|
||||
|
||||
- Issue: Several core services explicitly excluded from test coverage thresholds due to integration complexity
|
||||
- Files: `apps/server/vitest.config.ts` (line 22), explicitly excluded: `claude-usage-service.ts`, `mcp-test-service.ts`, `cli-provider.ts`, `cursor-provider.ts`
|
||||
- Impact: Usage tracking, MCP integration, and CLI detection could break undetected; regression detection is limited
|
||||
- Fix approach: Create integration test fixtures for CLI providers; mock MCP SDK for mcp-test-service tests; add usage tracking unit tests with mocked API calls
|
||||
|
||||
**Unused/Stub TODO Item Processing:**
|
||||
|
||||
- Issue: TodoWrite tool implementation exists but is partially integrated; tool name constants scattered across codex provider
|
||||
- Files: `apps/server/src/providers/codex-tool-mapping.ts`, `apps/server/src/providers/codex-provider.ts`
|
||||
- Impact: Todo list updates may not synchronize properly with all providers; unclear which providers support TodoWrite
|
||||
- Fix approach: Consolidate tool name constants; add provider capability flags for todo support
|
||||
|
||||
**Electron Electron.ts Size and Complexity:**
|
||||
|
||||
- Issue: Single 3741-line file handles all Electron IPC, native bindings, and communication
|
||||
- Files: `apps/ui/src/lib/electron.ts`
|
||||
- Impact: Difficult to test; hard to isolate bugs; changes require full testing of all features; potential memory overhead from monolithic file
|
||||
- Fix approach: Split by responsibility (IPC, window management, file operations, debug tools); create separate bridge layers
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**API Key Management Incomplete for Gemini:**
|
||||
|
||||
- Symptoms: Gemini API key verification endpoint not implemented despite other providers having verification
|
||||
- Files: `apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts` (line 122)
|
||||
- Trigger: User tries to verify Gemini API key in settings
|
||||
- Workaround: Key verification skipped for Gemini; settings page still accepts and stores key
|
||||
|
||||
**Orphaned Features Detection Vulnerable to False Negatives:**
|
||||
|
||||
- Symptoms: Features marked as orphaned when branch matching logic doesn't account for all scenarios
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 5714-5773)
|
||||
- Trigger: Features that were manually switched branches or rebased
|
||||
- Workaround: Manual cleanup via feature deletion; branch comparison is basic name matching only
|
||||
|
||||
**Terminal Themes Incomplete:**
|
||||
|
||||
- Symptoms: Light theme themes (solarizedlight, github) map to same generic lightTheme; no dedicated implementations
|
||||
- Files: `apps/ui/src/config/terminal-themes.ts` (lines 593-594)
|
||||
- Trigger: User selects solarizedlight or github terminal theme
|
||||
- Workaround: Uses generic light theme instead of specific scheme; visual appearance doesn't match expectation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Process Environment Variable Exposure:**
|
||||
|
||||
- Risk: Child processes inherit all parent `process.env` including sensitive credentials (API keys, tokens)
|
||||
- Files: `apps/server/src/providers/cursor-provider.ts` (line 993), `apps/server/src/providers/codex-provider.ts` (line 1099)
|
||||
- Current mitigation: Dotenv provides isolation at app startup; selective env passing to some providers
|
||||
- Recommendations: Use explicit allowlists for env vars passed to child processes (only pass REQUIRED_KEYS); audit all spawn calls for env handling; document which providers need which credentials
|
||||
|
||||
**Unvalidated Provider Tool Input:**
|
||||
|
||||
- Risk: Tool input from CLI providers (Cursor, Copilot, Codex) is partially validated through Record<string, unknown> patterns; execution context could be escaped
|
||||
- Files: `apps/server/src/providers/codex-provider.ts` (lines 506-543), `apps/server/src/providers/tool-normalization.ts`
|
||||
- Current mitigation: Status enums validated; tool names checked against allow-lists in some providers
|
||||
- Recommendations: Implement comprehensive schema validation for all tool inputs before execution; use zod or similar for runtime validation; add security tests for injection patterns
|
||||
|
||||
**API Key Storage in Settings Files:**
|
||||
|
||||
- Risk: API keys stored in plaintext in `~/.automaker/settings.json` and `data/settings.json`; file permissions may not be restricted
|
||||
- Files: `apps/server/src/services/settings-service.ts`, uses `atomicWriteJson` without file permission enforcement
|
||||
- Current mitigation: Limited by file system permissions; Electron mode has single-user access
|
||||
- Recommendations: Encrypt sensitive settings fields (apiKeys, tokens); use OS credential stores (Keychain/Credential Manager) for production; add file permission checks on startup
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Synchronous Feature Loading at Startup:**
|
||||
|
||||
- Problem: All features loaded synchronously at project load; blocks UI with 1000+ features
|
||||
- Files: `apps/server/src/services/feature-loader.ts` (line 230 Promise.all, but synchronous enumeration)
|
||||
- Cause: Feature directory walk and JSON parsing is not paginated or lazy-loaded
|
||||
- Improvement path: Implement lazy loading with pagination (load first 50, fetch more on scroll); add caching layer with TTL; move to background indexing; add feature count limits with warnings
|
||||
|
||||
**Auto-Mode Concurrency at Max Can Exceed Rate Limits:**
|
||||
|
||||
- Problem: maxConcurrency = 10 can quickly exhaust Claude API rate limits if all features execute simultaneously
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (line 2931 Promise.all for concurrent agents)
|
||||
- Cause: No adaptive backoff; no API usage tracking before queuing; hint mentions reducing concurrency but doesn't enforce it
|
||||
- Improvement path: Integrate with claude-usage-service to check remaining quota before starting features; implement exponential backoff on 429 errors; add per-model rate limit tracking
|
||||
|
||||
**Terminal Session Memory Leak Risk:**
|
||||
|
||||
- Problem: Terminal sessions accumulate in memory; expired sessions not cleaned up reliably
|
||||
- Files: `apps/server/src/routes/terminal/common.ts` (line 66 cleanup runs every 5 minutes, but only for tokens)
|
||||
- Cause: Cleanup interval is arbitrary; session map not bounded; no session lifespan limit
|
||||
- Improvement path: Implement LRU eviction with max session count; reduce cleanup interval to 1 minute; add memory usage monitoring; auto-close idle sessions after 30 minutes
|
||||
|
||||
**Large File Content Loading Without Limits:**
|
||||
|
||||
- Problem: File content loaded entirely into memory; `describe-file.ts` truncates at 50KB but loads all content first
|
||||
- Files: `apps/server/src/routes/context/routes/describe-file.ts` (line 128)
|
||||
- Cause: Synchronous file read; no streaming; no check before reading large files
|
||||
- Improvement path: Check file size before reading; stream large files; add file size warnings; implement chunked processing for analysis
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Provider Factory Model Resolution:**
|
||||
|
||||
- Files: `apps/server/src/providers/provider-factory.ts`, `apps/server/src/providers/simple-query-service.ts`
|
||||
- Why fragile: Each provider interprets model strings differently; no central registry; model aliases resolved at multiple layers (model-resolver, provider-specific maps, CLI validation)
|
||||
- Safe modification: Add integration tests for each model alias per provider; create model capability matrix; centralize model validation before dispatch
|
||||
- Test coverage: No dedicated tests; relies on E2E; no isolated unit tests for model resolution
|
||||
|
||||
**WebSocket Session Authentication:**
|
||||
|
||||
- Files: `apps/server/src/lib/auth.ts` (line 40 setInterval), `apps/server/src/index.ts` (token validation per message)
|
||||
- Why fragile: Session tokens generated and validated at multiple points; no single source of truth; expiration is not atomic
|
||||
- Safe modification: Add tests for token expiration edge cases; ensure cleanup removes all references; log all auth failures
|
||||
- Test coverage: Auth middleware tested, but not session lifecycle
|
||||
|
||||
**Auto-Mode Feature State Machine:**
|
||||
|
||||
- Files: `apps/server/src/services/auto-mode-service.ts` (lines 465-600)
|
||||
- Why fragile: Multiple states (running, queued, completed, error) managed across different methods; no explicit state transition validation; error recovery is defensive (catches all, logs, continues)
|
||||
- Safe modification: Create explicit state enum with valid transitions; add invariant checks; unit test state transitions with all error cases
|
||||
- Test coverage: Gaps in error recovery paths; no tests for concurrent state changes
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Feature Count Scalability:**
|
||||
|
||||
- Current capacity: ~1000 features tested; UI performance degrades with pagination required
|
||||
- Limit: 10K+ features cause >5s load times; memory usage ~100MB for metadata alone
|
||||
- Scaling path: Implement feature database instead of file-per-feature; add ElasticSearch indexing for search; paginate API responses (50 per page); add feature archiving
|
||||
|
||||
**Concurrent Auto-Mode Executions:**
|
||||
|
||||
- Current capacity: maxConcurrency = 10 features; limited by Claude API rate limits
|
||||
- Limit: Rate limit hits at ~4-5 simultaneous features with extended context (100K+ tokens)
|
||||
- Scaling path: Implement token usage budgeting before feature start; queue features with estimated token cost; add provider-specific rate limit handling
|
||||
|
||||
**Terminal Session Count:**
|
||||
|
||||
- Current capacity: ~100 active terminal sessions per server
|
||||
- Limit: Memory grows unbounded; no session count limit enforced
|
||||
- Scaling path: Add max session count with least-recently-used eviction; implement session federation for distributed setup
|
||||
|
||||
**Worktree Disk Usage:**
|
||||
|
||||
- Current capacity: 10K worktrees (~20GB with typical repos)
|
||||
- Limit: `.worktrees` directory grows without cleanup; old worktrees accumulate
|
||||
- Scaling path: Add worktree TTL (delete if not used for 30 days); implement cleanup job; add quota warnings at 50/80% disk
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**node-pty Beta Version:**
|
||||
|
||||
- Risk: `node-pty@1.1.0-beta41` used for terminal emulation; beta status indicates possible instability
|
||||
- Impact: Terminal features could break on minor platform changes; no guarantees on bug fixes
|
||||
- Migration plan: Monitor releases for stable version; pin to specific commit if needed; test extensively on target platforms (macOS, Linux, Windows)
|
||||
|
||||
**@anthropic-ai/claude-agent-sdk 0.1.x:**
|
||||
|
||||
- Risk: Pre-1.0 version; SDK API may change in future releases; limited version stability guarantees
|
||||
- Impact: Breaking changes could require significant refactoring; feature additions in SDK may not align with Automaker roadmap
|
||||
- Migration plan: Pin to specific 0.1.x version; review SDK changelogs before upgrades; maintain SDK compatibility tests; consider fallback implementation for critical paths
|
||||
|
||||
**@openai/codex-sdk 0.77.x:**
|
||||
|
||||
- Risk: Codex model deprecated by OpenAI; SDK may be archived or unsupported
|
||||
- Impact: Codex provider could become non-functional; error messages may not be actionable
|
||||
- Migration plan: Monitor OpenAI roadmap for migration path; implement fallback to Claude for Codex requests; add deprecation warning in UI
|
||||
|
||||
**Express 5.2.x RC Stage:**
|
||||
|
||||
- Risk: Express 5 is still in release candidate phase (as of Node 22); full stability not guaranteed
|
||||
- Impact: Minor version updates could include breaking changes; middleware compatibility issues possible
|
||||
- Migration plan: Maintain compatibility layer for Express 5 API; test with latest major before release; document any version-specific workarounds
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**Persistent Session Storage:**
|
||||
|
||||
- Problem: Agent conversation sessions stored only in-memory; restart loses all chat history
|
||||
- Blocks: Long-running analysis across server restarts; session recovery not possible
|
||||
- Impact: Users must re-run entire analysis if server restarts; lost productivity
|
||||
|
||||
**Rate Limit Awareness:**
|
||||
|
||||
- Problem: No tracking of API usage relative to rate limits before executing features
|
||||
- Blocks: Predictable concurrent feature execution; users frequently hit rate limits unexpectedly
|
||||
- Impact: Feature execution fails with cryptic rate limit errors; poor user experience
|
||||
|
||||
**Feature Dependency Visualization:**
|
||||
|
||||
- Problem: Dependency-resolver package exists but no UI to visualize or manage dependencies
|
||||
- Blocks: Users cannot plan feature order; complex dependencies not visible
|
||||
- Impact: Features implemented in wrong order; blocking dependencies missed
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**CLI Provider Integration:**
|
||||
|
||||
- What's not tested: Actual CLI execution paths; environment setup; error recovery from CLI crashes
|
||||
- Files: `apps/server/src/providers/cli-provider.ts`, `apps/server/src/lib/cli-detection.ts`
|
||||
- Risk: Changes to CLI handling could break silently; detection logic not validated on target platforms
|
||||
- Priority: High - affects all CLI-based providers (Cursor, Copilot, Codex)
|
||||
|
||||
**Cursor Provider Platform-Specific Paths:**
|
||||
|
||||
- What's not tested: Windows/Linux Cursor installation detection; version directory parsing; APPDATA environment variable handling
|
||||
- Files: `apps/server/src/providers/cursor-provider.ts` (lines 267-498)
|
||||
- Risk: Platform-specific bugs not caught; Cursor detection fails on non-standard installations
|
||||
- Priority: High - Cursor is primary provider; platform differences critical
|
||||
|
||||
**Event Hook System State Changes:**
|
||||
|
||||
- What's not tested: Concurrent hook execution; cleanup on server shutdown; webhook delivery retries
|
||||
- Files: `apps/server/src/services/event-hook-service.ts` (line 248 Promise.allSettled)
|
||||
- Risk: Hooks may not execute in expected order; memory not cleaned up; webhooks lost on failure
|
||||
- Priority: Medium - affects automation workflows
|
||||
|
||||
**Error Classification for New Providers:**
|
||||
|
||||
- What's not tested: Each provider's unique error patterns mapped to ErrorType enum; new provider errors not classified
|
||||
- Files: `apps/server/src/lib/error-handler.ts` (lines 58-80), each provider error mapping
|
||||
- Risk: User sees generic "unknown error" instead of actionable message; categorization regresses with new providers
|
||||
- Priority: Medium - impacts user experience
|
||||
|
||||
**Feature State Corruption Scenarios:**
|
||||
|
||||
- What's not tested: Concurrent feature updates; partial writes with power loss; JSON parsing recovery
|
||||
- Files: `apps/server/src/services/feature-loader.ts`, `@automaker/utils` (atomicWriteJson)
|
||||
- Risk: Feature data corrupted on concurrent access; recovery incomplete; no validation before use
|
||||
- Priority: High - data loss risk
|
||||
|
||||
---
|
||||
|
||||
_Concerns audit: 2026-01-27_
|
||||
@@ -1,255 +0,0 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
|
||||
- PascalCase for class/service files: `auto-mode-service.ts`, `feature-loader.ts`, `claude-provider.ts`
|
||||
- kebab-case for route/handler directories: `auto-mode/`, `features/`, `event-history/`
|
||||
- kebab-case for utility files: `secure-fs.ts`, `sdk-options.ts`, `settings-helpers.ts`
|
||||
- kebab-case for React components: `card.tsx`, `ansi-output.tsx`, `count-up-timer.tsx`
|
||||
- kebab-case for hooks: `use-board-background-settings.ts`, `use-responsive-kanban.ts`, `use-test-logs.ts`
|
||||
- kebab-case for store files: `app-store.ts`, `auth-store.ts`, `setup-store.ts`
|
||||
- Organized by functionality: `routes/features/routes/list.ts`, `routes/features/routes/get.ts`
|
||||
|
||||
**Functions:**
|
||||
|
||||
- camelCase for all function names: `createEventEmitter()`, `getAutomakerDir()`, `executeQuery()`
|
||||
- Verb-first for action functions: `buildPrompt()`, `classifyError()`, `loadContextFiles()`, `atomicWriteJson()`
|
||||
- Prefix with `use` for React hooks: `useBoardBackgroundSettings()`, `useAppStore()`, `useUpdateProjectSettings()`
|
||||
- Private methods prefixed with underscore: `_deleteOrphanedImages()`, `_migrateImages()`
|
||||
|
||||
**Variables:**
|
||||
|
||||
- camelCase for constants and variables: `featureId`, `projectPath`, `modelId`, `tempDir`
|
||||
- UPPER_SNAKE_CASE for global constants/enums: `DEFAULT_MAX_CONCURRENCY`, `DEFAULT_PHASE_MODELS`
|
||||
- Meaningful naming over abbreviations: `featureDirectory` not `fd`, `featureImages` not `img`
|
||||
- Prefixes for computed values: `is*` for booleans: `isClaudeModel`, `isContainerized`, `isAutoLoginEnabled`
|
||||
|
||||
**Types:**
|
||||
|
||||
- PascalCase for interfaces and types: `Feature`, `ExecuteOptions`, `EventEmitter`, `ProviderConfig`
|
||||
- Type files suffixed with `.d.ts`: `paths.d.ts`, `types.d.ts`
|
||||
- Organized by domain: `src/store/types/`, `src/lib/`
|
||||
- Re-export pattern from main package indexes: `export type { Feature };`
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
|
||||
- Tool: Prettier 3.7.4
|
||||
- Print width: 100 characters
|
||||
- Tab width: 2 spaces
|
||||
- Single quotes for strings
|
||||
- Semicolons required
|
||||
- Trailing commas: es5 (trailing in arrays/objects, not in params)
|
||||
- Arrow functions always include parentheses: `(x) => x * 2`
|
||||
- Line endings: LF (Unix)
|
||||
- Bracket spacing: `{ key: value }`
|
||||
|
||||
**Linting:**
|
||||
|
||||
- Tool: ESLint (flat config in `apps/ui/eslint.config.mjs`)
|
||||
- TypeScript ESLint plugin for `.ts`/`.tsx` files
|
||||
- Recommended configs: `@eslint/js`, `@typescript-eslint/recommended`
|
||||
- Unused variables warning with exception for parameters starting with `_`
|
||||
- Type assertions are allowed with description when using `@ts-ignore`
|
||||
- `@typescript-eslint/no-explicit-any` is warn-level (allow with caution)
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
|
||||
1. Node.js standard library: `import fs from 'fs/promises'`, `import path from 'path'`
|
||||
2. Third-party packages: `import { describe, it } from 'vitest'`, `import { Router } from 'express'`
|
||||
3. Shared packages (monorepo): `import type { Feature } from '@automaker/types'`, `import { createLogger } from '@automaker/utils'`
|
||||
4. Local relative imports: `import { FeatureLoader } from './feature-loader.js'`, `import * as secureFs from '../lib/secure-fs.js'`
|
||||
5. Type imports: separated with `import type { ... } from`
|
||||
|
||||
**Path Aliases:**
|
||||
|
||||
- `@/` - resolves to `./src` in both UI (`apps/ui/`) and server (`apps/server/`)
|
||||
- Shared packages prefixed with `@automaker/`:
|
||||
- `@automaker/types` - core TypeScript definitions
|
||||
- `@automaker/utils` - logging, errors, utilities
|
||||
- `@automaker/prompts` - AI prompt templates
|
||||
- `@automaker/platform` - path management, security, processes
|
||||
- `@automaker/model-resolver` - model alias resolution
|
||||
- `@automaker/dependency-resolver` - feature dependency ordering
|
||||
- `@automaker/git-utils` - git operations
|
||||
- Extensions: `.js` extension used in imports for ESM imports
|
||||
|
||||
**Import Rules:**
|
||||
|
||||
- Always import from shared packages, never from old paths
|
||||
- No circular dependencies between layers
|
||||
- Services import from providers and utilities
|
||||
- Routes import from services
|
||||
- Shared packages have strict dependency hierarchy (types → utils → platform → git-utils → server/ui)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Use `try-catch` blocks for async operations: wraps feature execution, file operations, git commands
|
||||
- Throw `new Error(message)` with descriptive messages: `throw new Error('already running')`, `throw new Error('Feature ${featureId} not found')`
|
||||
- Classify errors with `classifyError()` from `@automaker/utils` for categorization
|
||||
- Log errors with context using `createLogger()`: includes error classification
|
||||
- Return error info objects: `{ valid: false, errors: [...], warnings: [...] }`
|
||||
- Validation returns structured result: `{ valid, errors, warnings }` from provider `validateConfig()`
|
||||
|
||||
**Error Types:**
|
||||
|
||||
- Authentication errors: distinguish from validation/runtime errors
|
||||
- Path validation errors: caught by middleware in Express routes
|
||||
- File system errors: logged and recovery attempted with backups
|
||||
- SDK/API errors: classified and wrapped with context
|
||||
- Abort/cancellation errors: handled without stack traces (graceful shutdown)
|
||||
|
||||
**Error Messages:**
|
||||
|
||||
- Descriptive and actionable: not vague error codes
|
||||
- Include context when helpful: file paths, feature IDs, model names
|
||||
- User-friendly messages via `getUserFriendlyErrorMessage()` for client display
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:**
|
||||
|
||||
- Built-in `createLogger()` from `@automaker/utils`
|
||||
- Each module creates logger: `const logger = createLogger('ModuleName')`
|
||||
- Logger functions: `info()`, `warn()`, `error()`, `debug()`
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Log operation start and completion for significant operations
|
||||
- Log warnings for non-critical issues: file deletion failures, missing optional configs
|
||||
- Log errors with full error object: `logger.error('operation failed', error)`
|
||||
- Use module name as logger context: `createLogger('AutoMode')`, `createLogger('HttpClient')`
|
||||
- Avoid logging sensitive data (API keys, passwords)
|
||||
- No console.log in production code - use logger
|
||||
|
||||
**What to Log:**
|
||||
|
||||
- Feature execution start/completion
|
||||
- Error classification and recovery attempts
|
||||
- File operations (create, delete, migrate)
|
||||
- API calls and responses (in debug mode)
|
||||
- Async operation start/end
|
||||
- Warnings for deprecated patterns
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
|
||||
- Complex algorithms or business logic: explain the "why" not the "what"
|
||||
- Integration points: explain how modules communicate
|
||||
- Workarounds: explain the constraint that made the workaround necessary
|
||||
- Non-obvious performance implications
|
||||
- Edge cases and their handling
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
|
||||
- Used for public functions and classes
|
||||
- Document parameters with `@param`
|
||||
- Document return types with `@returns`
|
||||
- Document exceptions with `@throws`
|
||||
- Used for service classes: `/**\n * Module description\n * Manages: ...\n */`
|
||||
- Not required for simple getters/setters
|
||||
|
||||
**Example JSDoc Pattern:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Delete images that were removed from a feature
|
||||
*/
|
||||
private async deleteOrphanedImages(
|
||||
projectPath: string,
|
||||
oldPaths: Array<string>,
|
||||
newPaths: Array<string>
|
||||
): Promise<void> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
|
||||
- Keep functions under 100 lines when possible
|
||||
- Large services split into multiple related methods
|
||||
- Private helper methods extracted for complex logic
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- Use destructuring for object parameters with multiple properties
|
||||
- Document parameter types with TypeScript types
|
||||
- Optional parameters marked with `?`
|
||||
- Use `Record<string, unknown>` for flexible object parameters
|
||||
|
||||
**Return Values:**
|
||||
|
||||
- Explicit return types required for all public functions
|
||||
- Return structured objects for multiple values
|
||||
- Use `Promise<T>` for async functions
|
||||
- Async generators use `AsyncGenerator<T>` for streaming responses
|
||||
- Never implicitly return `undefined` (explicit return or throw)
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
|
||||
- Default export for class instantiation: `export default class FeatureLoader {}`
|
||||
- Named exports for functions: `export function createEventEmitter() {}`
|
||||
- Type exports separated: `export type { Feature };`
|
||||
- Barrel files (index.ts) re-export from module
|
||||
|
||||
**Barrel Files:**
|
||||
|
||||
- Used in routes: `routes/features/index.ts` creates router and exports
|
||||
- Used in stores: `store/index.ts` exports all store hooks
|
||||
- Pattern: group related exports for easier importing
|
||||
|
||||
**Service Classes:**
|
||||
|
||||
- Instantiated once and dependency injected
|
||||
- Public methods for API surface
|
||||
- Private methods prefixed with `_`
|
||||
- No static methods - prefer instances or functions
|
||||
- Constructor takes dependencies: `constructor(config?: ProviderConfig)`
|
||||
|
||||
**Provider Pattern:**
|
||||
|
||||
- Abstract base class: `BaseProvider` with abstract methods
|
||||
- Concrete implementations: `ClaudeProvider`, `CodexProvider`, `CursorProvider`
|
||||
- Common interface: `executeQuery()`, `detectInstallation()`, `validateConfig()`
|
||||
- Factory for instantiation: `ProviderFactory.create()`
|
||||
|
||||
## TypeScript Specific
|
||||
|
||||
**Strict Mode:** Always enabled globally
|
||||
|
||||
- `strict: true` in all tsconfigs
|
||||
- No implicit `any` - declare types explicitly
|
||||
- No optional chaining on base types without narrowing
|
||||
|
||||
**Type Definitions:**
|
||||
|
||||
- Interface for shapes: `interface Feature { ... }`
|
||||
- Type for unions/aliases: `type ModelAlias = 'haiku' | 'sonnet' | 'opus'`
|
||||
- Type guards for narrowing: `if (typeof x === 'string') { ... }`
|
||||
- Generic types for reusable patterns: `EventCallback<T>`
|
||||
|
||||
**React Specific (UI):**
|
||||
|
||||
- Functional components only
|
||||
- React 19 with hooks
|
||||
- Type props interface: `interface CardProps extends React.ComponentProps<'div'> { ... }`
|
||||
- Zustand stores for state management
|
||||
- Custom hooks for shared logic
|
||||
|
||||
---
|
||||
|
||||
_Convention analysis: 2026-01-27_
|
||||
@@ -1,232 +0,0 @@
|
||||
# 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_
|
||||
@@ -1,230 +0,0 @@
|
||||
# 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_
|
||||
@@ -1,340 +0,0 @@
|
||||
# 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_
|
||||
@@ -1,389 +0,0 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-01-27
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
|
||||
- Vitest 4.0.16 (for unit and integration tests)
|
||||
- Playwright (for E2E tests)
|
||||
- Config: `apps/server/vitest.config.ts`, `libs/*/vitest.config.ts`, `apps/ui/playwright.config.ts`
|
||||
|
||||
**Assertion Library:**
|
||||
|
||||
- Vitest built-in expect assertions
|
||||
- API: `expect().toBe()`, `expect().toEqual()`, `expect().toHaveLength()`, `expect().toHaveProperty()`
|
||||
|
||||
**Run Commands:**
|
||||
|
||||
```bash
|
||||
npm run test # E2E tests (Playwright, headless)
|
||||
npm run test:headed # E2E tests with browser visible
|
||||
npm run test:packages # All shared package unit tests (vitest)
|
||||
npm run test:server # Server unit tests (vitest run)
|
||||
npm run test:server:coverage # Server tests with coverage report
|
||||
npm run test:all # All tests (packages + server)
|
||||
npm run test:unit # Vitest run (all projects)
|
||||
npm run test:unit:watch # Vitest watch mode
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
|
||||
- Co-located with source: `src/module.ts` has `tests/unit/module.test.ts`
|
||||
- Server tests: `apps/server/tests/` (separate directory)
|
||||
- Library tests: `libs/*/tests/` (each package)
|
||||
- E2E tests: `apps/ui/tests/` (Playwright)
|
||||
|
||||
**Naming:**
|
||||
|
||||
- Pattern: `{moduleName}.test.ts` for unit tests
|
||||
- Pattern: `{moduleName}.spec.ts` for specification tests
|
||||
- Glob pattern: `tests/**/*.test.ts`, `tests/**/*.spec.ts`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```
|
||||
apps/server/
|
||||
├── tests/
|
||||
│ ├── setup.ts # Global test setup
|
||||
│ ├── unit/
|
||||
│ │ ├── providers/ # Provider tests
|
||||
│ │ │ ├── claude-provider.test.ts
|
||||
│ │ │ ├── codex-provider.test.ts
|
||||
│ │ │ └── base-provider.test.ts
|
||||
│ │ └── services/
|
||||
│ └── utils/
|
||||
│ └── helpers.ts # Test utilities
|
||||
└── src/
|
||||
|
||||
libs/platform/
|
||||
├── tests/
|
||||
│ ├── paths.test.ts
|
||||
│ ├── security.test.ts
|
||||
│ ├── subprocess.test.ts
|
||||
│ └── node-finder.test.ts
|
||||
└── src/
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { FeatureLoader } from '@/services/feature-loader.js';
|
||||
|
||||
describe('feature-loader.ts', () => {
|
||||
let featureLoader: FeatureLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
featureLoader = new FeatureLoader();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup resources
|
||||
});
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should do specific thing', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- Setup pattern: `beforeEach()` initializes test instance, clears mocks
|
||||
- Teardown pattern: `afterEach()` cleans up temp directories, removes created files
|
||||
- Assertion pattern: one logical assertion per test (or multiple closely related)
|
||||
- Test isolation: each test runs with fresh setup
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
|
||||
- Vitest `vi` module: `vi.mock()`, `vi.mocked()`, `vi.clearAllMocks()`
|
||||
- Mock patterns: module mocking, function spying, return value mocking
|
||||
|
||||
**Patterns:**
|
||||
|
||||
Module mocking:
|
||||
|
||||
```typescript
|
||||
vi.mock('@anthropic-ai/claude-agent-sdk');
|
||||
// In test:
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'Response 1' };
|
||||
})()
|
||||
);
|
||||
```
|
||||
|
||||
Async generator mocking (for streaming APIs):
|
||||
|
||||
```typescript
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
```
|
||||
|
||||
Partial mocking with spies:
|
||||
|
||||
```typescript
|
||||
const provider = new TestProvider();
|
||||
const spy = vi.spyOn(provider, 'getName');
|
||||
spy.mockReturnValue('mocked-name');
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
|
||||
- External APIs (Claude SDK, GitHub SDK, cloud services)
|
||||
- File system operations (use temp directories instead when possible)
|
||||
- Network calls
|
||||
- Process execution
|
||||
- Time-dependent operations
|
||||
|
||||
**What NOT to Mock:**
|
||||
|
||||
- Core business logic (test the actual implementation)
|
||||
- Type definitions
|
||||
- Internal module dependencies (test integration with real services)
|
||||
- Standard library functions (fs, path, etc. - use fixtures instead)
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
|
||||
```typescript
|
||||
// Test helper for collecting async generator results
|
||||
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of generator) {
|
||||
results.push(item);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Temporary directory fixture
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
|
||||
projectPath = path.join(tempDir, 'test-project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Location:**
|
||||
|
||||
- Inline in test files for simple fixtures
|
||||
- `tests/utils/helpers.ts` for shared test utilities
|
||||
- Factory functions for complex test objects: `createTestProvider()`, `createMockFeature()`
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements (Server):**
|
||||
|
||||
- Lines: 60%
|
||||
- Functions: 75%
|
||||
- Branches: 55%
|
||||
- Statements: 60%
|
||||
- Config: `apps/server/vitest.config.ts` with thresholds
|
||||
|
||||
**Excluded from Coverage:**
|
||||
|
||||
- Route handlers: tested via integration/E2E tests
|
||||
- Type re-exports
|
||||
- Middleware: tested via integration tests
|
||||
- Prompt templates
|
||||
- MCP integration: awaits MCP SDK integration tests
|
||||
- Provider CLI integrations: awaits integration tests
|
||||
|
||||
**View Coverage:**
|
||||
|
||||
```bash
|
||||
npm run test:server:coverage # Generate coverage report
|
||||
# Opens HTML report in: apps/server/coverage/index.html
|
||||
```
|
||||
|
||||
**Coverage Tools:**
|
||||
|
||||
- Provider: v8
|
||||
- Reporters: text, json, html, lcov
|
||||
- File inclusion: `src/**/*.ts`
|
||||
- File exclusion: `src/**/*.d.ts`, specific service files in thresholds
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
- Scope: Individual functions and methods
|
||||
- Approach: Test inputs → outputs with mocked dependencies
|
||||
- Location: `apps/server/tests/unit/`
|
||||
- Examples:
|
||||
- Provider executeQuery() with mocked SDK
|
||||
- Path construction functions with assertions
|
||||
- Error classification with different error types
|
||||
- Config validation with various inputs
|
||||
|
||||
**Integration Tests:**
|
||||
|
||||
- Scope: Multiple modules working together
|
||||
- Approach: Test actual service calls with real file system or temp directories
|
||||
- Pattern: Setup data → call method → verify results
|
||||
- Example: Feature loader reading/writing feature.json files
|
||||
- Example: Auto-mode service coordinating with multiple services
|
||||
|
||||
**E2E Tests:**
|
||||
|
||||
- Framework: Playwright
|
||||
- Scope: Full user workflows from UI
|
||||
- Location: `apps/ui/tests/`
|
||||
- Config: `apps/ui/playwright.config.ts`
|
||||
- Setup:
|
||||
- Backend server with mock agent enabled
|
||||
- Frontend Vite dev server
|
||||
- Sequential execution (workers: 1) to avoid auth conflicts
|
||||
- Screenshots/traces on failure
|
||||
- Auth: Global setup authentication in `tests/global-setup.ts`
|
||||
- Fixtures: `tests/e2e-fixtures/` for test project data
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Testing:**
|
||||
|
||||
```typescript
|
||||
it('should execute async operation', async () => {
|
||||
const result = await featureLoader.loadFeature(projectPath, featureId);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(featureId);
|
||||
});
|
||||
|
||||
// For streams/generators:
|
||||
const generator = provider.executeQuery({ prompt, model, cwd });
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
expect(results).toHaveLength(2);
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
|
||||
```typescript
|
||||
it('should throw error when feature not found', async () => {
|
||||
await expect(featureLoader.getFeature(projectPath, 'nonexistent')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
// Testing error classification:
|
||||
const errorInfo = classifyError(new Error('ENOENT'));
|
||||
expect(errorInfo.category).toBe('FileSystem');
|
||||
```
|
||||
|
||||
**Fixture Setup:**
|
||||
|
||||
```typescript
|
||||
it('should create feature with images', async () => {
|
||||
// Setup: create temp feature directory
|
||||
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
// Act: perform operation
|
||||
const result = await featureLoader.updateFeature(projectPath, {
|
||||
id: featureId,
|
||||
imagePaths: ['/temp/image.png'],
|
||||
});
|
||||
|
||||
// Assert: verify file operations
|
||||
const migratedPath = path.join(featureDir, 'images', 'image.png');
|
||||
expect(fs.existsSync(migratedPath)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
**Mock Reset Pattern:**
|
||||
|
||||
```typescript
|
||||
// In vitest.config.ts:
|
||||
mockReset: true, // Reset all mocks before each test
|
||||
restoreMocks: true, // Restore original implementations
|
||||
clearMocks: true, // Clear mock call history
|
||||
|
||||
// In test:
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
});
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
**Vitest Config Patterns:**
|
||||
|
||||
Server config (`apps/server/vitest.config.ts`):
|
||||
|
||||
- Environment: node
|
||||
- Globals: true (describe/it without imports)
|
||||
- Setup files: `./tests/setup.ts`
|
||||
- Alias resolution: resolves `@automaker/*` to source files for mocking
|
||||
|
||||
Library config:
|
||||
|
||||
- Simpler setup: just environment and globals
|
||||
- Coverage with high thresholds (90%+ lines)
|
||||
|
||||
**Global Setup:**
|
||||
|
||||
```typescript
|
||||
// tests/setup.ts
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DATA_DIR = '/tmp/test-data';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
**Isolation:**
|
||||
|
||||
- Each test is independent (no state sharing)
|
||||
- Cleanup temp files in afterEach
|
||||
- Reset mocks and environment variables in beforeEach
|
||||
|
||||
**Clarity:**
|
||||
|
||||
- Descriptive test names: "should do X when Y condition"
|
||||
- One logical assertion per test
|
||||
- Clear arrange-act-assert structure
|
||||
|
||||
**Speed:**
|
||||
|
||||
- Mock external services
|
||||
- Use in-memory temp directories
|
||||
- Avoid real network calls
|
||||
- Sequential E2E tests to prevent conflicts
|
||||
|
||||
**Maintainability:**
|
||||
|
||||
- Use beforeEach/afterEach for common setup
|
||||
- Extract test helpers to `tests/utils/`
|
||||
- Keep test data simple and local
|
||||
- Mock consistently across tests
|
||||
|
||||
---
|
||||
|
||||
_Testing analysis: 2026-01-27_
|
||||
@@ -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 { AutoModeServiceCompat } from './services/auto-mode/index.js';
|
||||
import { AutoModeService } from './services/auto-mode-service.js';
|
||||
import { getTerminalService } from './services/terminal-service.js';
|
||||
import { SettingsService } from './services/settings-service.js';
|
||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||
@@ -258,9 +258,7 @@ const events: EventEmitter = createEventEmitter();
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// Auto-mode services: compatibility layer provides old interface while using new architecture
|
||||
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const codexAppServerService = new CodexAppServerService();
|
||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||
@@ -392,7 +390,7 @@ const server = createServer(app);
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
|
||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Terminal Theme Data - Re-export terminal themes from platform package
|
||||
*
|
||||
* This module re-exports terminal theme data for use in the server.
|
||||
*/
|
||||
|
||||
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return getThemeColors(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all terminal themes
|
||||
*/
|
||||
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
|
||||
return terminalThemeColors;
|
||||
}
|
||||
|
||||
export default terminalThemeColors;
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Auto Mode routes - HTTP API for autonomous feature implementation
|
||||
*
|
||||
* Uses AutoModeServiceCompat which provides the old interface while
|
||||
* delegating to GlobalAutoModeService and per-project facades.
|
||||
* Uses the AutoModeService for real feature execution with Claude Agent SDK
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
@@ -22,12 +21,7 @@ import { createCommitFeatureHandler } from './routes/commit-feature.js';
|
||||
import { createApprovePlanHandler } from './routes/approve-plan.js';
|
||||
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
|
||||
/**
|
||||
* Create auto-mode routes.
|
||||
*
|
||||
* @param autoModeService - AutoModeServiceCompat instance
|
||||
*/
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createAnalyzeProjectHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createApprovePlanHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
|
||||
@@ -48,11 +48,11 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
|
||||
|
||||
// Resolve the pending approval (with recovery support)
|
||||
const result = await autoModeService.resolvePlanApproval(
|
||||
projectPath || '',
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback
|
||||
feedback,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createCommitFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, worktreePath } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createContextExistsHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
|
||||
@@ -30,12 +30,16 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCom
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createResumeFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
|
||||
const logger = createLogger('ResumeInterrupted');
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ResumeInterruptedRequest {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createResumeInterruptedHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { projectPath } = req.body as ResumeInterruptedRequest;
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createResumeInterruptedHandler(autoModeService: AutoModeServiceC
|
||||
|
||||
try {
|
||||
await autoModeService.resumeInterruptedFeatures(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Resume check completed',
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
@@ -50,6 +50,10 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
|
||||
.catch((error) => {
|
||||
logger.error(`Feature ${featureId} error:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
// Release the starting slot when execution completes (success or error)
|
||||
// Note: The feature should be in runningFeatures by this point
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create status handler.
|
||||
*/
|
||||
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
@@ -24,7 +21,6 @@ export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
|
||||
if (projectPath) {
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
@@ -42,7 +38,7 @@ export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Global status for backward compatibility
|
||||
// Fall back to global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createStopFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { featureId } = req.body as { featureId: string };
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
|
||||
export function createVerifyFeatureHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
|
||||
@@ -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 { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.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?: AutoModeServiceCompat
|
||||
autoModeService?: AutoModeService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
|
||||
@@ -7,16 +7,13 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.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: AutoModeServiceCompat,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type {
|
||||
AutoModeServiceCompat,
|
||||
RunningAgentInfo,
|
||||
ProjectAutoModeStatus,
|
||||
} from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
@@ -151,7 +147,7 @@ function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeServiceCompat,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
@@ -162,7 +158,7 @@ export function createOverviewHandler(
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents();
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
@@ -173,10 +169,7 @@ export function createOverviewHandler(
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus: ProjectAutoModeStatus = autoModeService.getStatusForProject(
|
||||
projectRef.path,
|
||||
null
|
||||
);
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import { createIndexHandler } from './routes/index.js';
|
||||
|
||||
export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router {
|
||||
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/', createIndexHandler(autoModeService));
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.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: AutoModeServiceCompat) {
|
||||
export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningAgents = [...(await autoModeService.getRunningAgents())];
|
||||
|
||||
const backlogPlanStatus = getBacklogPlanStatus();
|
||||
const backlogPlanDetails = getRunningDetails();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { setLogLevel, LogLevel } from '@automaker/utils';
|
||||
import { setRequestLoggingEnabled } from '../../../index.js';
|
||||
import { getTerminalService } from '../../../services/terminal-service.js';
|
||||
|
||||
/**
|
||||
* Map server log level string to LogLevel enum
|
||||
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
|
||||
// Get old settings to detect theme changes
|
||||
const oldSettings = await settingsService.getGlobalSettings();
|
||||
const oldTheme = oldSettings?.theme;
|
||||
|
||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
logger.info(
|
||||
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
settings.projects?.length ?? 0
|
||||
);
|
||||
|
||||
// Handle theme change - regenerate terminal RC files for all projects
|
||||
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
const newTheme = updates.theme;
|
||||
|
||||
logger.info(
|
||||
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
|
||||
);
|
||||
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects || [];
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
// Check if terminal config is enabled (global or project-specific)
|
||||
const terminalConfigEnabled =
|
||||
projectSettings.terminalConfig?.enabled !== false &&
|
||||
settings.terminalConfig?.enabled === true;
|
||||
|
||||
if (terminalConfigEnabled) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply server log level if it was updated
|
||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
||||
|
||||
@@ -43,10 +43,14 @@ export function createInitGitHandler() {
|
||||
// .git doesn't exist, continue with initialization
|
||||
}
|
||||
|
||||
// Initialize git and create an initial empty commit
|
||||
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
|
||||
// and create an initial empty commit
|
||||
await execAsync(
|
||||
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
|
||||
{
|
||||
cwd: projectPath,
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* AgentExecutor Types - Type definitions for agent execution
|
||||
*/
|
||||
|
||||
import type {
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
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;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
branchName?: string | null;
|
||||
credentials?: Credentials;
|
||||
claudeCompatibleProvider?: ClaudeCompatibleProvider;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
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<void>;
|
||||
|
||||
export type UpdateFeatureSummaryFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
summary: string
|
||||
) => Promise<void>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
/**
|
||||
* AgentExecutor - Core agent execution engine with streaming support
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
||||
import { buildPromptWithImages, createLogger } 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');
|
||||
|
||||
export class AgentExecutor {
|
||||
private static readonly WRITE_DEBOUNCE_MS = 500;
|
||||
private static readonly STREAM_HEARTBEAT_MS = 15_000;
|
||||
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private featureStateManager: FeatureStateManager,
|
||||
private planApprovalService: PlanApprovalService,
|
||||
private settingsService: SettingsService | null = null
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
options: AgentExecutionOptions,
|
||||
callbacks: AgentExecutorCallbacks
|
||||
): Promise<AgentExecutionResult> {
|
||||
const {
|
||||
workDir,
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
previousContent,
|
||||
planningMode = 'skip',
|
||||
requirePlanApproval = false,
|
||||
specAlreadyDetected = false,
|
||||
existingApprovedPlanContent,
|
||||
persistedTasks,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
options.prompt,
|
||||
options.imagePaths,
|
||||
workDir,
|
||||
false
|
||||
);
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: promptContent,
|
||||
model: effectiveBareModel,
|
||||
maxTurns: sdkOptions?.maxTurns,
|
||||
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<string, { command: string }>)
|
||||
: undefined,
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
};
|
||||
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<typeof setTimeout> | null = null,
|
||||
rawOutputLines: string[] = [],
|
||||
rawWriteTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const writeToFile = async (): Promise<void> => {
|
||||
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 }, null, 4)
|
||||
);
|
||||
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) {
|
||||
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;
|
||||
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."
|
||||
);
|
||||
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') {
|
||||
throw new Error(msg.error || 'Unknown error');
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
||||
}
|
||||
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 */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(streamHeartbeat);
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
}
|
||||
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 taskStream = provider.executeQuery(
|
||||
this.buildExecOpts(options, taskPrompt, Math.min(sdkOptions?.maxTurns || 100, 50))
|
||||
);
|
||||
let taskOutput = '',
|
||||
taskStartDetected = false,
|
||||
taskCompleteDetected = false;
|
||||
|
||||
for await (const msg of taskStream) {
|
||||
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')
|
||||
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 (!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 {
|
||||
workDir,
|
||||
featureId,
|
||||
projectPath,
|
||||
abortController,
|
||||
branchName = null,
|
||||
planningMode = 'skip',
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
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 || 100)
|
||||
)) {
|
||||
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,
|
||||
content: b.text,
|
||||
});
|
||||
}
|
||||
if (msg.type === 'error') throw new Error(msg.error || 'Error during plan revision');
|
||||
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,
|
||||
mcpServers:
|
||||
o.mcpServers && Object.keys(o.mcpServers).length > 0
|
||||
? (o.mcpServers as Record<string, { command: string }>)
|
||||
: undefined,
|
||||
credentials: o.credentials,
|
||||
claudeCompatibleProvider: o.claudeCompatibleProvider,
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
)) {
|
||||
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')
|
||||
throw new Error(msg.error || 'Unknown error during implementation');
|
||||
else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || '';
|
||||
}
|
||||
return { responseText };
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking
|
||||
*/
|
||||
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
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;
|
||||
}
|
||||
|
||||
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
||||
return `${projectPath}::${(branchName === 'main' ? null : branchName) ?? '__main__'}`;
|
||||
}
|
||||
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean
|
||||
) => Promise<void>;
|
||||
export type LoadPendingFeaturesFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<Feature[]>;
|
||||
export type SaveExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => Promise<void>;
|
||||
export type ClearExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<void>;
|
||||
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
|
||||
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
|
||||
|
||||
export class AutoLoopCoordinator {
|
||||
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
||||
|
||||
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
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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<number> {
|
||||
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<void> {
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) return;
|
||||
const { projectPath, branchName } = projectState.config;
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
const nextFeature = pendingFeatures.find(
|
||||
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
|
||||
);
|
||||
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<number> {
|
||||
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<string>();
|
||||
for (const [, state] of this.autoLoopsByProject) {
|
||||
if (state.isRunning) activeProjects.add(state.config.projectPath);
|
||||
}
|
||||
return Array.from(activeProjects);
|
||||
}
|
||||
|
||||
async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
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<number> {
|
||||
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') {
|
||||
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<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
5805
apps/server/src/services/auto-mode-service.ts
Normal file
5805
apps/server/src/services/auto-mode-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 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 { 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;
|
||||
|
||||
constructor(
|
||||
events: EventEmitter,
|
||||
settingsService: SettingsService | null,
|
||||
featureLoader: FeatureLoader
|
||||
) {
|
||||
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
|
||||
const sharedServices = this.globalService.getSharedServices();
|
||||
|
||||
this.facadeOptions = {
|
||||
events,
|
||||
settingsService,
|
||||
featureLoader,
|
||||
sharedServices,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global service for direct access
|
||||
*/
|
||||
getGlobalService(): GlobalAutoModeService {
|
||||
return this.globalService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a facade for a specific project
|
||||
*/
|
||||
createFacade(projectPath: string): AutoModeServiceFacade {
|
||||
return AutoModeServiceFacade.create(projectPath, this.facadeOptions);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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<RunningAgentInfo[]> {
|
||||
return this.globalService.getRunningAgents();
|
||||
}
|
||||
|
||||
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
|
||||
return this.globalService.markAllRunningFeaturesInterrupted(reason);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// PER-PROJECT OPERATIONS (delegated to facades)
|
||||
// ===========================================================================
|
||||
|
||||
getStatusForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): {
|
||||
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<number> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.startAutoLoop(branchName, maxConcurrency);
|
||||
}
|
||||
|
||||
async stopAutoLoopForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): Promise<number> {
|
||||
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<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.executeFeature(
|
||||
featureId,
|
||||
useWorktrees,
|
||||
isAutoMode,
|
||||
providedWorktreePath,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
// 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<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.resumeFeature(featureId, useWorktrees);
|
||||
}
|
||||
|
||||
async followUpFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
useWorktrees = true
|
||||
): Promise<void> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees);
|
||||
}
|
||||
|
||||
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.verifyFeature(featureId);
|
||||
}
|
||||
|
||||
async commitFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
providedWorktreePath?: string
|
||||
): Promise<string | null> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.commitFeature(featureId, providedWorktreePath);
|
||||
}
|
||||
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.contextExists(featureId);
|
||||
}
|
||||
|
||||
async analyzeProject(projectPath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<Array<{ feature: Feature; missingBranch: string }>> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.detectOrphanedFeatures();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* 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 type { Feature } from '@automaker/types';
|
||||
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
|
||||
(pPath, branchName) =>
|
||||
featureLoader
|
||||
.getAll(pPath)
|
||||
.then((features) =>
|
||||
features.filter(
|
||||
(f) =>
|
||||
(f.status === 'backlog' || f.status === 'ready') &&
|
||||
(branchName === null
|
||||
? !f.branchName || f.branchName === 'main'
|
||||
: 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<RunningAgentInfo[]> {
|
||||
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;
|
||||
}
|
||||
} 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<void> {
|
||||
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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
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';
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RunningAgentInfo[]>;
|
||||
/** Mark all running features as interrupted (for graceful shutdown) */
|
||||
markAllRunningFeaturesInterrupted(reason?: string): Promise<void>;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* 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<string | null>;
|
||||
|
||||
/**
|
||||
* 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<string, RunningFeature>();
|
||||
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)
|
||||
* @returns Number of running features for the worktree
|
||||
*/
|
||||
async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
// Get the actual primary branch name for the project
|
||||
const primaryBranch = await this.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently running features
|
||||
*
|
||||
* @returns Array of all RunningFeature entries
|
||||
*/
|
||||
getAllRunning(): RunningFeature[] {
|
||||
return Array.from(this.runningFeatures.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RunningFeature>): void {
|
||||
const entry = this.runningFeatures.get(featureId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(entry, updates);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
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 type { PipelineContext } from './pipeline-orchestrator.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');
|
||||
|
||||
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<void> {
|
||||
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`);
|
||||
|
||||
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 = 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);
|
||||
tempRunningFeature.worktreePath = worktreePath;
|
||||
tempRunningFeature.branchName = branchName ?? null;
|
||||
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 prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||
let prompt: string;
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[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,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
});
|
||||
}
|
||||
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
this.recordSuccessFn();
|
||||
|
||||
try {
|
||||
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let agentOutput = '';
|
||||
try {
|
||||
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
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<typeof recordMemoryUsage>[4]
|
||||
);
|
||||
}
|
||||
await this.recordLearningsFn(projectPath, feature, agentOutput);
|
||||
} catch {
|
||||
/* learnings recording failed */
|
||||
}
|
||||
|
||||
this.eventBus.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.eventBus.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.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<boolean> {
|
||||
const running = this.concurrencyManager.getRunningFeature(featureId);
|
||||
if (!running) return false;
|
||||
running.abortController.abort();
|
||||
this.releaseRunningFeature(featureId, { force: true });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* 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 } 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;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
branchName?: string | null;
|
||||
}
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to execute pipeline steps
|
||||
*/
|
||||
export type ExecutePipelineFn = (context: PipelineContext) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to update feature status
|
||||
*/
|
||||
export type UpdateFeatureStatusFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to load a feature by ID
|
||||
*/
|
||||
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
|
||||
|
||||
/**
|
||||
* Function to get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Function to save a feature summary
|
||||
*/
|
||||
export type SaveFeatureSummaryFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
summary: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to record learnings from a completed feature
|
||||
*/
|
||||
export type RecordLearningsFn = (
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
agentOutput: string
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to check if context exists for a feature
|
||||
*/
|
||||
export type ContextExistsFn = (projectPath: string, featureId: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Function to resume a feature (continues from saved context or starts fresh)
|
||||
*/
|
||||
export type ResumeFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
_calledInternally: boolean
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>
|
||||
) => Promise<void>;
|
||||
|
||||
// =============================================================================
|
||||
// AutoLoopCoordinator Callback Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Function to execute a feature in auto-loop
|
||||
*/
|
||||
export type AutoLoopExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to load pending features for a worktree
|
||||
*/
|
||||
export type LoadPendingFeaturesFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<Feature[]>;
|
||||
|
||||
/**
|
||||
* Function to save execution state for auto-loop
|
||||
*/
|
||||
export type AutoLoopSaveExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to clear execution state
|
||||
*/
|
||||
export type ClearExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Function to reset stuck features
|
||||
*/
|
||||
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,446 +0,0 @@
|
||||
/**
|
||||
* 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 { 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<Feature | null> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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<void> {
|
||||
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<Feature | null>(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;
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
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<void> {
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* - 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<void> {
|
||||
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<Feature | null>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PlanSpec>
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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 && 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 });
|
||||
} 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 <summary> 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<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(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,
|
||||
});
|
||||
}
|
||||
} 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: string, data: Record<string, unknown>): void {
|
||||
// Wrap the event in auto-mode:event format expected by the client
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
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 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,
|
||||
private serverPort = 3008
|
||||
) {}
|
||||
|
||||
async executePipeline(ctx: PipelineContext): Promise<void> {
|
||||
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
|
||||
ctx;
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[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,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
}
|
||||
);
|
||||
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<PipelineStatusInfo> {
|
||||
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<void> {
|
||||
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);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
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<void> {
|
||||
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);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
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);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
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 context: PipelineContext = {
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
steps: stepsToExecute,
|
||||
workDir,
|
||||
worktreePath,
|
||||
branchName: branchName ?? null,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await this.executePipeline(context);
|
||||
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
logger.info(`Pipeline resume completed for feature ${featureId}`);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
passes: true,
|
||||
message: 'Pipeline resumed successfully',
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
if (errorInfo.isAbort) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
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<StepResult> {
|
||||
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);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
testsPassed: false,
|
||||
message: `Tests failed after ${maxTestAttempts} attempts`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for test completion */
|
||||
private async waitForTestCompletion(
|
||||
sessionId: string
|
||||
): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
const session = this.testRunnerService.getSession(sessionId);
|
||||
if (session && session.status !== 'running' && session.status !== 'pending') {
|
||||
clearInterval(checkInterval);
|
||||
resolve({
|
||||
status: session.status,
|
||||
exitCode: session.exitCode,
|
||||
duration: session.finishedAt
|
||||
? session.finishedAt.getTime() - session.startedAt.getTime()
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ status: 'failed', exitCode: null, duration: 600000 });
|
||||
}, 600000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Attempt to merge feature branch (REQ-F05) */
|
||||
async attemptMerge(context: PipelineContext): Promise<MergeResult> {
|
||||
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 {
|
||||
const response = await fetch(`http://localhost:${this.serverPort}/api/worktree/merge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath,
|
||||
branchName,
|
||||
worktreePath,
|
||||
targetBranch: 'main',
|
||||
options: { deleteWorktreeAndBranch: false },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: 'No response from merge endpoint' };
|
||||
}
|
||||
|
||||
// Defensively parse JSON response
|
||||
let data: { success: boolean; hasConflicts?: boolean; error?: string };
|
||||
try {
|
||||
data = (await response.json()) as {
|
||||
success: boolean;
|
||||
hasConflicts?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
} catch (parseError) {
|
||||
logger.error(`Failed to parse merge response:`, parseError);
|
||||
return { success: false, error: 'Invalid response from merge endpoint' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.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: data.error };
|
||||
}
|
||||
|
||||
logger.info(`Auto-merge successful for feature ${featureId}`);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName,
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a concise test failure summary for the agent */
|
||||
buildTestFailureSummary(scrollback: string): string {
|
||||
const lines = scrollback.split('\n');
|
||||
const failedTests: string[] = [];
|
||||
let passCount = 0,
|
||||
failCount = 0;
|
||||
|
||||
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++;
|
||||
} else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) passCount++;
|
||||
if (trimmed.match(/^>\s+.*\.(test|spec)\./)) failedTests.push(trimmed.replace(/^>\s+/, ''));
|
||||
if (
|
||||
trimmed.includes('AssertionError') ||
|
||||
trimmed.includes('toBe') ||
|
||||
trimmed.includes('toEqual')
|
||||
)
|
||||
failedTests.push(trimmed);
|
||||
}
|
||||
|
||||
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: string[] = [];
|
||||
for (const line of scrollback.split('\n')) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
return [...new Set(failedTests)].slice(0, 20);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
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<void>;
|
||||
|
||||
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<void>;
|
||||
|
||||
export type RunAgentFn = (
|
||||
workDir: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
projectPath: string,
|
||||
imagePaths?: string[],
|
||||
model?: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* 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<string, PendingApproval>();
|
||||
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<PlanApprovalResult> {
|
||||
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) => {
|
||||
// Set up timeout to prevent indefinite waiting and memory leaks
|
||||
// timeoutId stored in closure, NOT in PendingApproval object
|
||||
const 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);
|
||||
reject(
|
||||
new Error(
|
||||
`Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Wrap resolve/reject to clear timeout when approval is resolved
|
||||
// This ensures timeout is ALWAYS cleared on any resolution path
|
||||
const wrappedResolve = (result: PlanApprovalResult) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const wrappedReject = (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
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<ResolveApprovalResult> {
|
||||
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,
|
||||
content: editedPlan, // Update content if user provided an edited version
|
||||
});
|
||||
|
||||
// If rejected with feedback, emit event so client knows the rejection reason
|
||||
if (!approved && feedback) {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* 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<void>;
|
||||
export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise<Feature | null>;
|
||||
export type DetectPipelineStatusFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: FeatureStatusWithPipeline
|
||||
) => Promise<PipelineStatusInfo>;
|
||||
export type ResumePipelineFn = (
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
useWorktrees: boolean,
|
||||
pipelineInfo: PipelineStatusInfo
|
||||
) => Promise<void>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ExecutionState> {
|
||||
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<void> {
|
||||
try {
|
||||
await secureFs.unlink(getExecutionStatePath(projectPath));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
try {
|
||||
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<Feature | null>(
|
||||
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;
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
(await this.contextExists(projectPath, feature.id))
|
||||
? featuresWithContext.push(feature)
|
||||
: featuresWithoutContext.push(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext];
|
||||
if (allInterruptedFeatures.length === 0) return;
|
||||
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 */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* 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 <summary> 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>): RegExpMatchArray | null => {
|
||||
const arr = [...matches];
|
||||
return arr.length > 0 ? arr[arr.length - 1] : null;
|
||||
};
|
||||
|
||||
// Check for explicit <summary> tags first (use last match to avoid stale summaries)
|
||||
const summaryMatches = text.matchAll(/<summary>([\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;
|
||||
}
|
||||
@@ -13,6 +13,14 @@ import * as path from 'path';
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
|
||||
import {
|
||||
getRcFilePath,
|
||||
getTerminalDir,
|
||||
ensureRcFilesUpToDate,
|
||||
type TerminalConfig,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
@@ -24,6 +32,27 @@ import {
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const BASH_LOGIN_ARG = '--login';
|
||||
const BASH_RCFILE_ARG = '--rcfile';
|
||||
const SHELL_NAME_BASH = 'bash';
|
||||
const SHELL_NAME_ZSH = 'zsh';
|
||||
const SHELL_NAME_SH = 'sh';
|
||||
const DEFAULT_SHOW_USER_HOST = true;
|
||||
const DEFAULT_SHOW_PATH = true;
|
||||
const DEFAULT_SHOW_TIME = false;
|
||||
const DEFAULT_SHOW_EXIT_STATUS = false;
|
||||
const DEFAULT_PATH_DEPTH = 0;
|
||||
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||
const DEFAULT_CUSTOM_PROMPT = true;
|
||||
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
|
||||
const DEFAULT_SHOW_GIT_BRANCH = true;
|
||||
const DEFAULT_SHOW_GIT_STATUS = true;
|
||||
const DEFAULT_CUSTOM_ALIASES = '';
|
||||
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
|
||||
const PROMPT_THEME_CUSTOM = 'custom';
|
||||
const PROMPT_THEME_PREFIX = 'omp-';
|
||||
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
|
||||
const sanitizedArgs: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === BASH_LOGIN_ARG) {
|
||||
continue;
|
||||
}
|
||||
if (arg === BASH_RCFILE_ARG) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
sanitizedArgs.push(arg);
|
||||
}
|
||||
|
||||
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
|
||||
return sanitizedArgs;
|
||||
}
|
||||
|
||||
function normalizePathStyle(
|
||||
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||
): TerminalConfig['pathStyle'] {
|
||||
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||
return pathStyle;
|
||||
}
|
||||
return DEFAULT_PATH_STYLE;
|
||||
}
|
||||
|
||||
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||
const depth =
|
||||
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||
}
|
||||
|
||||
function getShellBasename(shellPath: string): string {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
}
|
||||
|
||||
function getShellArgsForPath(shellPath: string): string[] {
|
||||
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
if (shellName === SHELL_NAME_SH) {
|
||||
return [];
|
||||
}
|
||||
return [BASH_LOGIN_ARG];
|
||||
}
|
||||
|
||||
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
|
||||
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
|
||||
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig: TerminalConfig | undefined,
|
||||
projectTerminalConfig: Partial<TerminalConfig> | undefined
|
||||
): TerminalConfig {
|
||||
const mergedEnvVars = {
|
||||
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
|
||||
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
|
||||
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
|
||||
showGitBranch:
|
||||
projectTerminalConfig?.showGitBranch ??
|
||||
globalTerminalConfig?.showGitBranch ??
|
||||
DEFAULT_SHOW_GIT_BRANCH,
|
||||
showGitStatus:
|
||||
projectTerminalConfig?.showGitStatus ??
|
||||
globalTerminalConfig?.showGitStatus ??
|
||||
DEFAULT_SHOW_GIT_STATUS,
|
||||
showUserHost:
|
||||
projectTerminalConfig?.showUserHost ??
|
||||
globalTerminalConfig?.showUserHost ??
|
||||
DEFAULT_SHOW_USER_HOST,
|
||||
showPath:
|
||||
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
|
||||
pathStyle: normalizePathStyle(
|
||||
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
|
||||
),
|
||||
pathDepth: normalizePathDepth(
|
||||
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
|
||||
),
|
||||
showTime:
|
||||
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
|
||||
showExitStatus:
|
||||
projectTerminalConfig?.showExitStatus ??
|
||||
globalTerminalConfig?.showExitStatus ??
|
||||
DEFAULT_SHOW_EXIT_STATUS,
|
||||
customAliases:
|
||||
projectTerminalConfig?.customAliases ??
|
||||
globalTerminalConfig?.customAliases ??
|
||||
DEFAULT_CUSTOM_ALIASES,
|
||||
customEnvVars: mergedEnvVars,
|
||||
rcFileVersion: globalTerminalConfig?.rcFileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
private settingsService: SettingsService | null = null;
|
||||
|
||||
constructor(settingsService?: SettingsService) {
|
||||
super();
|
||||
this.settingsService = settingsService || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
|
||||
|
||||
// Validate and resolve working directory
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config injection (custom prompts, themes)
|
||||
const terminalConfigEnv: Record<string, string> = {};
|
||||
if (this.settingsService) {
|
||||
try {
|
||||
logger.info(
|
||||
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
|
||||
);
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const projectSettings = options.cwd
|
||||
? await this.settingsService.getProjectSettings(options.cwd)
|
||||
: null;
|
||||
|
||||
const globalTerminalConfig = globalSettings?.terminalConfig;
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig,
|
||||
projectTerminalConfig
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
|
||||
);
|
||||
logger.info(
|
||||
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
|
||||
);
|
||||
|
||||
if (effectiveConfig.enabled && globalTerminalConfig) {
|
||||
const currentTheme = globalSettings?.theme || 'dark';
|
||||
const themeColors = getTerminalThemeColors(currentTheme);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
const promptTheme =
|
||||
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
|
||||
const ompThemeName = resolveOmpThemeName(promptTheme);
|
||||
|
||||
// Ensure RC files are up to date
|
||||
await ensureRcFilesUpToDate(
|
||||
options.cwd || cwd,
|
||||
currentTheme,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = getShellBasename(shell).toLowerCase();
|
||||
if (ompThemeName && effectiveConfig.customPrompt) {
|
||||
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
|
||||
}
|
||||
|
||||
if (shellName.includes(SHELL_NAME_BASH)) {
|
||||
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
|
||||
terminalConfigEnv.BASH_ENV = bashRcFilePath;
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
|
||||
} else if (shellName.includes(SHELL_NAME_ZSH)) {
|
||||
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === SHELL_NAME_SH) {
|
||||
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
|
||||
// Add custom env vars from config
|
||||
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
||||
...options.env,
|
||||
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
|
||||
};
|
||||
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle theme change - regenerate RC files with new theme colors
|
||||
*/
|
||||
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('[onThemeChange] SettingsService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
|
||||
|
||||
if (effectiveConfig.enabled && terminalConfig) {
|
||||
const themeColors = getTerminalThemeColors(
|
||||
newTheme as import('@automaker/types').ThemeMode
|
||||
);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
|
||||
// Regenerate RC files with new theme
|
||||
await ensureRcFilesUpToDate(
|
||||
projectPath,
|
||||
newTheme as import('@automaker/types').ThemeMode,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
|
||||
// Singleton instance
|
||||
let terminalService: TerminalService | null = null;
|
||||
|
||||
export function getTerminalService(): TerminalService {
|
||||
export function getTerminalService(settingsService?: SettingsService): TerminalService {
|
||||
if (!terminalService) {
|
||||
terminalService = new TerminalService();
|
||||
terminalService = new TerminalService(settingsService);
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
| 'pipeline_step_started'
|
||||
| 'pipeline_step_complete'
|
||||
| string; // Allow other strings for extensibility
|
||||
|
||||
/**
|
||||
* 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<string, unknown>): 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;
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* 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<string | null> {
|
||||
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<string | null> {
|
||||
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<WorktreeInfo[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ export interface TestRepo {
|
||||
export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
|
||||
|
||||
// Initialize git repo
|
||||
await execAsync('git init', { cwd: tmpDir });
|
||||
// Initialize git repo with 'main' as the default branch (matching GitHub's standard)
|
||||
await execAsync('git init --initial-branch=main', { cwd: tmpDir });
|
||||
|
||||
// Use environment variables instead of git config to avoid affecting user's git config
|
||||
// These env vars override git config without modifying it
|
||||
@@ -38,9 +38,6 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
||||
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
||||
|
||||
// Create main branch explicitly
|
||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||
|
||||
return {
|
||||
path: tmpDir,
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ describe('worktree create route - repositories without commits', () => {
|
||||
|
||||
async function initRepoWithoutCommit() {
|
||||
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
||||
await execAsync('git init', { cwd: repoPath });
|
||||
// Initialize with 'main' as the default branch (matching GitHub's standard)
|
||||
await execAsync('git init --initial-branch=main', { cwd: repoPath });
|
||||
// Don't set git config - use environment variables in commit operations instead
|
||||
// to avoid affecting user's git config
|
||||
// Intentionally skip creating an initial commit
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,935 +0,0 @@
|
||||
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-20250514',
|
||||
};
|
||||
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-20250514',
|
||||
};
|
||||
|
||||
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-20250514');
|
||||
});
|
||||
|
||||
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-20250514',
|
||||
// Optional fields
|
||||
imagePaths: ['/image1.png', '/image2.png'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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 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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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-20250514',
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,610 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
AutoLoopCoordinator,
|
||||
getWorktreeAutoLoopKey,
|
||||
type AutoModeConfig,
|
||||
type ProjectAutoLoopState,
|
||||
type ExecuteFeatureFn,
|
||||
type LoadPendingFeaturesFn,
|
||||
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 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([]);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
845
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
845
apps/server/tests/unit/services/auto-mode-service.test.ts
Normal file
@@ -0,0 +1,845 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
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<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { get: mockFn };
|
||||
};
|
||||
|
||||
it('should return empty array when no agents are running', async () => {
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return running agents with basic info when feature data is not available', async () => {
|
||||
// Arrange: Add a running feature to the Map
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-123', {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// Mock featureLoader.get to return null (feature not found)
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/test/project/path',
|
||||
projectName: 'path',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return running agents with title and description when feature data is available', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-456', {
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const mockFeature: Partial<Feature> = {
|
||||
id: 'feature-456',
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
category: 'auth',
|
||||
};
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(mockFeature);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-456',
|
||||
projectPath: '/home/user/my-project',
|
||||
projectName: 'my-project',
|
||||
isAutoMode: false,
|
||||
title: 'Implement user authentication',
|
||||
description: 'Add login and signup functionality',
|
||||
});
|
||||
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
|
||||
});
|
||||
|
||||
it('should handle multiple running agents', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-1',
|
||||
title: 'Feature One',
|
||||
description: 'Description one',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'feature-2',
|
||||
title: 'Feature Two',
|
||||
description: 'Description two',
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should silently handle errors when fetching feature data', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-error', {
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act - should not throw
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
featureId: 'feature-error',
|
||||
projectPath: '/project-error',
|
||||
projectName: 'project-error',
|
||||
isAutoMode: true,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle feature with title but no description', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-title-only', {
|
||||
featureId: 'feature-title-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-title-only',
|
||||
title: 'Only Title',
|
||||
// description is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBe('Only Title');
|
||||
expect(result[0].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle feature with description but no title', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-desc-only', {
|
||||
featureId: 'feature-desc-only',
|
||||
projectPath: '/project',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue({
|
||||
id: 'feature-desc-only',
|
||||
description: 'Only description, no title',
|
||||
// title is undefined
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].title).toBeUndefined();
|
||||
expect(result[0].description).toBe('Only description, no title');
|
||||
});
|
||||
|
||||
it('should extract projectName from nested paths correctly', async () => {
|
||||
// Arrange
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-nested', {
|
||||
featureId: 'feature-nested',
|
||||
projectPath: '/home/user/workspace/projects/my-awesome-project',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const getMock = vi.fn().mockResolvedValue(null);
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const result = await service.getRunningAgents();
|
||||
|
||||
// Assert
|
||||
expect(result[0].projectName).toBe('my-awesome-project');
|
||||
});
|
||||
|
||||
it('should fetch feature data in parallel for multiple agents', async () => {
|
||||
// Arrange: Add multiple running features
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
runningFeaturesMap.set(`feature-${i}`, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
// Simulate async delay to verify parallel execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { id: featureId, title: `Title for ${featureId}` };
|
||||
});
|
||||
mockFeatureLoaderGet(service, getMock);
|
||||
|
||||
// Act
|
||||
const startTime = Date.now();
|
||||
const result = await service.getRunningAgents();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(5);
|
||||
expect(getMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms (one batch)
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
// Allow some buffer for execution overhead
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectOrphanedFeatures', () => {
|
||||
// Helper to mock featureLoader.getAll
|
||||
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(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<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(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<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,609 +0,0 @@
|
||||
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<GetCurrentBranchFn>;
|
||||
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,16 @@ import net from 'net';
|
||||
|
||||
describe('dev-server-service.ts', () => {
|
||||
let testDir: string;
|
||||
let originalHostname: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Store and set HOSTNAME for consistent test behavior
|
||||
originalHostname = process.env.HOSTNAME;
|
||||
process.env.HOSTNAME = 'localhost';
|
||||
|
||||
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
@@ -56,6 +61,13 @@ describe('dev-server-service.ts', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore original HOSTNAME
|
||||
if (originalHostname === undefined) {
|
||||
delete process.env.HOSTNAME;
|
||||
} else {
|
||||
process.env.HOSTNAME = originalHostname;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,659 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
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';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
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(
|
||||
'/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 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,458 +0,0 @@
|
||||
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');
|
||||
// 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');
|
||||
// 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');
|
||||
// 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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,665 +0,0 @@
|
||||
/**
|
||||
* 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 { RecoveryService, DEFAULT_EXECUTION_STATE } from '@/services/recovery-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
// 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<typeof vi.fn>;
|
||||
let mockLoadFeature: ReturnType<typeof vi.fn>;
|
||||
let mockDetectPipelineStatus: ReturnType<typeof vi.fn>;
|
||||
let mockResumePipeline: ReturnType<typeof vi.fn>;
|
||||
let mockIsFeatureRunning: ReturnType<typeof vi.fn>;
|
||||
let mockAcquireRunningFeature: ReturnType<typeof vi.fn>;
|
||||
let mockReleaseRunningFeature: ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
'/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 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('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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,641 +0,0 @@
|
||||
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 <summary> tags', () => {
|
||||
it('should extract content from summary tags', () => {
|
||||
const text = 'Some preamble <summary>This is the summary content</summary> more text';
|
||||
expect(extractSummary(text)).toBe('This is the summary content');
|
||||
});
|
||||
|
||||
it('should use last match to avoid stale summaries', () => {
|
||||
const text = `
|
||||
<summary>Old stale summary</summary>
|
||||
|
||||
More agent output...
|
||||
|
||||
<summary>Fresh new summary</summary>
|
||||
`;
|
||||
expect(extractSummary(text)).toBe('Fresh new summary');
|
||||
});
|
||||
|
||||
it('should handle multiline summary content', () => {
|
||||
const text = `<summary>First line
|
||||
Second line
|
||||
Third line</summary>`;
|
||||
expect(extractSummary(text)).toBe('First line\nSecond line\nThird line');
|
||||
});
|
||||
|
||||
it('should trim whitespace from summary', () => {
|
||||
const text = '<summary> trimmed content </summary>';
|
||||
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 <summary> over ## Summary', () => {
|
||||
const text = `
|
||||
## Summary
|
||||
|
||||
Markdown summary
|
||||
|
||||
<summary>Tagged summary</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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,299 +0,0 @@
|
||||
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<EventCallback>;
|
||||
} {
|
||||
const subscribers = new Set<EventCallback>();
|
||||
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<typeof createMockEventEmitter>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(payload.type).toBe('auto_mode_tool');
|
||||
expect(payload.tool).toEqual({
|
||||
name: 'write_file',
|
||||
input: {
|
||||
path: '/src/index.ts',
|
||||
content: 'const x = 1;',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { WorktreeResolver, type WorktreeInfo } from '@/services/worktree-resolver.js';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
// 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 path = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x');
|
||||
|
||||
expect(path).toBe('/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 path = await resolver.findWorktreeForBranch('/Users/dev/project', 'main');
|
||||
|
||||
expect(path).toBe('/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 path = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x');
|
||||
|
||||
expect(path).toBe('/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
|
||||
expect(result).toBe('/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: '/Users/dev/project',
|
||||
branch: 'main',
|
||||
isMain: true,
|
||||
});
|
||||
expect(worktrees[1]).toEqual({
|
||||
path: '/Users/dev/project/.worktrees/feature-x',
|
||||
branch: 'feature-x',
|
||||
isMain: false,
|
||||
});
|
||||
expect(worktrees[2]).toEqual({
|
||||
path: '/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: '/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('/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: '/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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
|
||||
import { join, dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -112,6 +112,29 @@ execSync('npm install --omit=dev', {
|
||||
},
|
||||
});
|
||||
|
||||
// Step 6b: Replace symlinks for local packages with real copies
|
||||
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
|
||||
console.log('🔗 Replacing symlinks with real directory copies...');
|
||||
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
|
||||
for (const pkgName of LOCAL_PACKAGES) {
|
||||
const pkgDir = pkgName.replace('@automaker/', '');
|
||||
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
|
||||
try {
|
||||
// lstatSync does not follow symlinks, allowing us to check for broken ones
|
||||
if (lstatSync(nmPkgPath).isSymbolicLink()) {
|
||||
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
|
||||
rmSync(nmPkgPath);
|
||||
cpSync(realPath, nmPkgPath, { recursive: true });
|
||||
console.log(` ✓ Replaced symlink: ${pkgName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Rebuild native modules for current architecture
|
||||
// This is critical for modules like node-pty that have native bindings
|
||||
console.log('🔨 Rebuilding native modules for current architecture...');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||
@@ -11,9 +11,12 @@ import { NotificationBell } from './components/notification-bell';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
|
||||
import {
|
||||
MACOS_ELECTRON_TOP_PADDING_CLASS,
|
||||
SIDEBAR_FEATURE_FLAGS,
|
||||
} from '@/components/layout/sidebar/constants';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||
@@ -279,7 +282,12 @@ export function ProjectSwitcher() {
|
||||
data-testid="project-switcher"
|
||||
>
|
||||
{/* Automaker Logo and Version */}
|
||||
<div className="flex flex-col items-center pt-3 pb-2 px-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center pb-2 px-2',
|
||||
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
className="group flex flex-col items-center gap-0.5"
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { isElectron, type Project } from '@/lib/electron';
|
||||
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
@@ -89,7 +90,7 @@ export function SidebarHeader({
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -240,7 +241,7 @@ export function SidebarHeader({
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||
)}
|
||||
>
|
||||
{/* Header with logo and project dropdown */}
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isMac } from '@/lib/utils';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
|
||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import type { NavSection } from '../types';
|
||||
@@ -117,7 +119,12 @@ export function SidebarNavigation({
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
// Add top padding in discord mode since there's no header
|
||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
||||
// Extra padding for macOS Electron to avoid traffic light overlap
|
||||
sidebarStyle === 'discord'
|
||||
? isMac && isElectron()
|
||||
? MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||
: 'pt-3'
|
||||
: 'mt-1'
|
||||
)}
|
||||
>
|
||||
{/* Project name display for classic/discord mode */}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
|
||||
/**
|
||||
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
|
||||
* This padding is applied conditionally when running on macOS in Electron.
|
||||
*/
|
||||
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
|
||||
|
||||
/**
|
||||
* Shared constants for theme submenu positioning and layout.
|
||||
* Used across project-context-menu and project-selector-with-options components
|
||||
|
||||
@@ -116,9 +116,8 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
},
|
||||
copilot: {
|
||||
viewBox: '0 0 98 96',
|
||||
// Official GitHub Octocat logo mark
|
||||
// Official GitHub Octocat logo mark (theme-aware via currentColor)
|
||||
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
|
||||
fill: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1275,8 +1275,10 @@ export function BoardView() {
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
onConcurrencyChange={(newMaxConcurrency) => {
|
||||
if (currentProject && selectedWorktree) {
|
||||
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
||||
if (currentProject) {
|
||||
// If selectedWorktree is undefined or it's the main worktree, branchName will be null.
|
||||
// Otherwise, use the branch name.
|
||||
const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null;
|
||||
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
||||
|
||||
// Persist to server settings so capacity checks use the correct value
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Prompt Preview - Shows a live preview of the custom terminal prompt
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import { getTerminalTheme } from '@/config/terminal-themes';
|
||||
|
||||
interface PromptPreviewProps {
|
||||
format: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
theme: ThemeMode;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
isOmpTheme?: boolean;
|
||||
promptThemeLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PromptPreview({
|
||||
format,
|
||||
theme,
|
||||
showGitBranch,
|
||||
showGitStatus,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
isOmpTheme = false,
|
||||
promptThemeLabel,
|
||||
className,
|
||||
}: PromptPreviewProps) {
|
||||
const terminalTheme = getTerminalTheme(theme);
|
||||
|
||||
const formatPath = (inputPath: string) => {
|
||||
let displayPath = inputPath;
|
||||
let prefix = '';
|
||||
|
||||
if (displayPath.startsWith('~/')) {
|
||||
prefix = '~/';
|
||||
displayPath = displayPath.slice(2);
|
||||
} else if (displayPath.startsWith('/')) {
|
||||
prefix = '/';
|
||||
displayPath = displayPath.slice(1);
|
||||
}
|
||||
|
||||
const segments = displayPath.split('/').filter((segment) => segment.length > 0);
|
||||
const depth = Math.max(0, pathDepth);
|
||||
const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments;
|
||||
|
||||
let formattedSegments = trimmedSegments;
|
||||
if (pathStyle === 'basename' && trimmedSegments.length > 0) {
|
||||
formattedSegments = [trimmedSegments[trimmedSegments.length - 1]];
|
||||
} else if (pathStyle === 'short') {
|
||||
formattedSegments = trimmedSegments.map((segment, index) => {
|
||||
if (index < trimmedSegments.length - 1) {
|
||||
return segment.slice(0, 1);
|
||||
}
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
const joined = formattedSegments.join('/');
|
||||
if (prefix === '/' && joined.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (prefix === '~/' && joined.length === 0) {
|
||||
return '~';
|
||||
}
|
||||
return `${prefix}${joined}`;
|
||||
};
|
||||
|
||||
// Generate preview text based on format
|
||||
const renderPrompt = () => {
|
||||
if (isOmpTheme) {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-2">
|
||||
<div style={{ color: terminalTheme.magenta }}>
|
||||
{promptThemeLabel ?? 'Oh My Posh theme'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Rendered by the oh-my-posh CLI in the terminal.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Preview here stays generic to avoid misleading output.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = 'user';
|
||||
const host = 'automaker';
|
||||
const path = formatPath('~/projects/automaker');
|
||||
const branch = showGitBranch ? 'main' : null;
|
||||
const dirty = showGitStatus && showGitBranch ? '*' : '';
|
||||
const time = showTime ? '[14:32]' : '';
|
||||
const status = showExitStatus ? '✗ 1' : '';
|
||||
|
||||
const gitInfo = branch ? ` (${branch}${dirty})` : '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<span style={{ color: terminalTheme.cyan }}>
|
||||
{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>{' '}
|
||||
</span>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}>{path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineSegments: ReactNode[] = [];
|
||||
if (showUserHost) {
|
||||
powerlineSegments.push(
|
||||
<span key="user-host" style={{ color: terminalTheme.cyan }}>
|
||||
[{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showPath) {
|
||||
powerlineSegments.push(
|
||||
<span key="path" style={{ color: terminalTheme.yellow }}>
|
||||
[{path}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineCore = powerlineSegments.flatMap((segment, index) =>
|
||||
index === 0
|
||||
? [segment]
|
||||
: [
|
||||
<span key={`sep-${index}`} style={{ color: terminalTheme.cyan }}>
|
||||
─
|
||||
</span>,
|
||||
segment,
|
||||
]
|
||||
);
|
||||
const powerlineExtras: ReactNode[] = [];
|
||||
if (gitInfo) {
|
||||
powerlineExtras.push(
|
||||
<span key="git" style={{ color: terminalTheme.magenta }}>
|
||||
{gitInfo}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showTime) {
|
||||
powerlineExtras.push(
|
||||
<span key="time" style={{ color: terminalTheme.magenta }}>
|
||||
{time}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showExitStatus) {
|
||||
powerlineExtras.push(
|
||||
<span key="status" style={{ color: terminalTheme.red }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineLine: ReactNode[] = [...powerlineCore];
|
||||
if (powerlineExtras.length > 0) {
|
||||
if (powerlineLine.length > 0) {
|
||||
powerlineLine.push(' ');
|
||||
}
|
||||
powerlineLine.push(...powerlineExtras);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>┌─</span>
|
||||
{powerlineLine}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>└─</span>
|
||||
<span style={{ color: terminalTheme.green }}>$</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> in </span>
|
||||
<span style={{ color: terminalTheme.yellow }}>{path}</span>
|
||||
</>
|
||||
)}
|
||||
{branch && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> on </span>
|
||||
<span style={{ color: terminalTheme.magenta }}>
|
||||
{branch}
|
||||
{dirty}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.green }}>❯</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>[{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
<span style={{ color: terminalTheme.cyan }}>]</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}> {path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-[var(--terminal-bg)] text-[var(--terminal-fg)]',
|
||||
'shadow-inner',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--terminal-bg': terminalTheme.background,
|
||||
'--terminal-fg': terminalTheme.foreground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="mb-2 text-xs text-muted-foreground opacity-70">Preview</div>
|
||||
{renderPrompt()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
|
||||
export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom';
|
||||
|
||||
export const OMP_THEME_NAMES = [
|
||||
'1_shell',
|
||||
'M365Princess',
|
||||
'agnoster',
|
||||
'agnoster.minimal',
|
||||
'agnosterplus',
|
||||
'aliens',
|
||||
'amro',
|
||||
'atomic',
|
||||
'atomicBit',
|
||||
'avit',
|
||||
'blue-owl',
|
||||
'blueish',
|
||||
'bubbles',
|
||||
'bubblesextra',
|
||||
'bubblesline',
|
||||
'capr4n',
|
||||
'catppuccin',
|
||||
'catppuccin_frappe',
|
||||
'catppuccin_latte',
|
||||
'catppuccin_macchiato',
|
||||
'catppuccin_mocha',
|
||||
'cert',
|
||||
'chips',
|
||||
'cinnamon',
|
||||
'clean-detailed',
|
||||
'cloud-context',
|
||||
'cloud-native-azure',
|
||||
'cobalt2',
|
||||
'craver',
|
||||
'darkblood',
|
||||
'devious-diamonds',
|
||||
'di4am0nd',
|
||||
'dracula',
|
||||
'easy-term',
|
||||
'emodipt',
|
||||
'emodipt-extend',
|
||||
'fish',
|
||||
'free-ukraine',
|
||||
'froczh',
|
||||
'gmay',
|
||||
'glowsticks',
|
||||
'grandpa-style',
|
||||
'gruvbox',
|
||||
'half-life',
|
||||
'honukai',
|
||||
'hotstick.minimal',
|
||||
'hul10',
|
||||
'hunk',
|
||||
'huvix',
|
||||
'if_tea',
|
||||
'illusi0n',
|
||||
'iterm2',
|
||||
'jandedobbeleer',
|
||||
'jblab_2021',
|
||||
'jonnychipz',
|
||||
'json',
|
||||
'jtracey93',
|
||||
'jv_sitecorian',
|
||||
'kali',
|
||||
'kushal',
|
||||
'lambda',
|
||||
'lambdageneration',
|
||||
'larserikfinholt',
|
||||
'lightgreen',
|
||||
'marcduiker',
|
||||
'markbull',
|
||||
'material',
|
||||
'microverse-power',
|
||||
'mojada',
|
||||
'montys',
|
||||
'mt',
|
||||
'multiverse-neon',
|
||||
'negligible',
|
||||
'neko',
|
||||
'night-owl',
|
||||
'nordtron',
|
||||
'nu4a',
|
||||
'onehalf.minimal',
|
||||
'paradox',
|
||||
'pararussel',
|
||||
'patriksvensson',
|
||||
'peru',
|
||||
'pixelrobots',
|
||||
'plague',
|
||||
'poshmon',
|
||||
'powerlevel10k_classic',
|
||||
'powerlevel10k_lean',
|
||||
'powerlevel10k_modern',
|
||||
'powerlevel10k_rainbow',
|
||||
'powerline',
|
||||
'probua.minimal',
|
||||
'pure',
|
||||
'quick-term',
|
||||
'remk',
|
||||
'robbyrussell',
|
||||
'rudolfs-dark',
|
||||
'rudolfs-light',
|
||||
'sim-web',
|
||||
'slim',
|
||||
'slimfat',
|
||||
'smoothie',
|
||||
'sonicboom_dark',
|
||||
'sonicboom_light',
|
||||
'sorin',
|
||||
'space',
|
||||
'spaceship',
|
||||
'star',
|
||||
'stelbent-compact.minimal',
|
||||
'stelbent.minimal',
|
||||
'takuya',
|
||||
'the-unnamed',
|
||||
'thecyberden',
|
||||
'tiwahu',
|
||||
'tokyo',
|
||||
'tokyonight_storm',
|
||||
'tonybaloney',
|
||||
'uew',
|
||||
'unicorn',
|
||||
'velvet',
|
||||
'wholespace',
|
||||
'wopian',
|
||||
'xtoys',
|
||||
'ys',
|
||||
'zash',
|
||||
] as const;
|
||||
|
||||
type OmpThemeName = (typeof OMP_THEME_NAMES)[number];
|
||||
|
||||
type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
type PathStyle = 'full' | 'short' | 'basename';
|
||||
|
||||
export interface PromptThemeConfig {
|
||||
promptFormat: PromptFormat;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: PathStyle;
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
}
|
||||
|
||||
export interface PromptThemePreset {
|
||||
id: TerminalPromptTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
config: PromptThemeConfig;
|
||||
}
|
||||
|
||||
const PATH_DEPTH_FULL = 0;
|
||||
const PATH_DEPTH_TWO = 2;
|
||||
const PATH_DEPTH_THREE = 3;
|
||||
|
||||
const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie'];
|
||||
const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible'];
|
||||
const STARSHIP_HINTS = ['spaceship', 'star'];
|
||||
const SHORT_PATH_HINTS = ['compact', 'lean', 'slim'];
|
||||
const TIME_HINTS = ['time', 'clock'];
|
||||
const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error'];
|
||||
|
||||
function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme {
|
||||
return `omp-${name}` as TerminalPromptTheme;
|
||||
}
|
||||
|
||||
function formatLabel(name: string): string {
|
||||
const cleaned = name.replace(/[._-]+/g, ' ').trim();
|
||||
return cleaned
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildPresetConfig(name: OmpThemeName): PromptThemeConfig {
|
||||
const lower = name.toLowerCase();
|
||||
const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint));
|
||||
const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint));
|
||||
const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint));
|
||||
let promptFormat: PromptFormat = 'standard';
|
||||
|
||||
if (isPowerline) {
|
||||
promptFormat = 'powerline';
|
||||
} else if (isMinimal) {
|
||||
promptFormat = 'minimal';
|
||||
} else if (isStarship) {
|
||||
promptFormat = 'starship';
|
||||
}
|
||||
|
||||
const showUserHost = !isMinimal;
|
||||
const showPath = true;
|
||||
const pathStyle: PathStyle = isMinimal ? 'short' : 'full';
|
||||
let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL;
|
||||
|
||||
if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) {
|
||||
pathDepth = PATH_DEPTH_TWO;
|
||||
}
|
||||
|
||||
if (lower.includes('powerlevel10k')) {
|
||||
pathDepth = PATH_DEPTH_THREE;
|
||||
}
|
||||
|
||||
const showTime = TIME_HINTS.some((hint) => lower.includes(hint));
|
||||
const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint));
|
||||
|
||||
return {
|
||||
promptFormat,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({
|
||||
id: toPromptThemeId(name),
|
||||
label: `${formatLabel(name)} (OMP)`,
|
||||
description: 'Oh My Posh theme preset',
|
||||
config: buildPresetConfig(name),
|
||||
}));
|
||||
|
||||
export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null {
|
||||
return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null;
|
||||
}
|
||||
|
||||
export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme {
|
||||
const match = PROMPT_THEME_PRESETS.find((preset) => {
|
||||
const presetConfig = preset.config;
|
||||
return (
|
||||
presetConfig.promptFormat === config.promptFormat &&
|
||||
presetConfig.showGitBranch === config.showGitBranch &&
|
||||
presetConfig.showGitStatus === config.showGitStatus &&
|
||||
presetConfig.showUserHost === config.showUserHost &&
|
||||
presetConfig.showPath === config.showPath &&
|
||||
presetConfig.pathStyle === config.pathStyle &&
|
||||
presetConfig.pathDepth === config.pathDepth &&
|
||||
presetConfig.showTime === config.showTime &&
|
||||
presetConfig.showExitStatus === config.showExitStatus
|
||||
);
|
||||
});
|
||||
|
||||
return match?.id ?? PROMPT_THEME_CUSTOM_ID;
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* Terminal Config Section - Custom terminal configurations with theme synchronization
|
||||
*
|
||||
* This component provides UI for enabling custom terminal prompts that automatically
|
||||
* sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs
|
||||
* in .automaker/terminal/ without modifying user's existing RC files.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { PromptPreview } from './prompt-preview';
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
import {
|
||||
PROMPT_THEME_CUSTOM_ID,
|
||||
PROMPT_THEME_PRESETS,
|
||||
getMatchingPromptThemeId,
|
||||
getPromptThemePreset,
|
||||
type PromptThemeConfig,
|
||||
} from './prompt-theme-presets';
|
||||
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||
import { useGlobalSettings } from '@/hooks/queries/use-settings';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
export function TerminalConfigSection() {
|
||||
const PATH_DEPTH_MIN = 0;
|
||||
const PATH_DEPTH_MAX = 10;
|
||||
const ENV_VAR_UPDATE_DEBOUNCE_MS = 400;
|
||||
const ENV_VAR_ID_PREFIX = 'env';
|
||||
const TERMINAL_RC_FILE_VERSION = 11;
|
||||
const { theme } = useAppStore();
|
||||
const { data: globalSettings } = useGlobalSettings();
|
||||
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||
const envVarIdRef = useRef(0);
|
||||
const envVarUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const createEnvVarEntry = useCallback(
|
||||
(key = '', value = '') => {
|
||||
envVarIdRef.current += 1;
|
||||
return {
|
||||
id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`,
|
||||
key,
|
||||
value,
|
||||
};
|
||||
},
|
||||
[ENV_VAR_ID_PREFIX]
|
||||
);
|
||||
const [localEnvVars, setLocalEnvVars] = useState<
|
||||
Array<{ id: string; key: string; value: string }>
|
||||
>(() =>
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
|
||||
|
||||
const clampPathDepth = (value: number) =>
|
||||
Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value));
|
||||
|
||||
const defaultTerminalConfig = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: PROMPT_THEME_CUSTOM_ID,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: PATH_DEPTH_MIN,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
};
|
||||
|
||||
const terminalConfig = {
|
||||
...defaultTerminalConfig,
|
||||
...globalSettings?.terminalConfig,
|
||||
customAliases:
|
||||
globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases,
|
||||
customEnvVars:
|
||||
globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars,
|
||||
};
|
||||
|
||||
const promptThemeConfig: PromptThemeConfig = {
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
};
|
||||
|
||||
const storedPromptTheme = terminalConfig.promptTheme;
|
||||
const activePromptThemeId =
|
||||
storedPromptTheme === PROMPT_THEME_CUSTOM_ID
|
||||
? PROMPT_THEME_CUSTOM_ID
|
||||
: (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig));
|
||||
const isOmpTheme =
|
||||
storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID;
|
||||
const promptThemePreset = isOmpTheme
|
||||
? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme)
|
||||
: null;
|
||||
|
||||
const applyEnabledUpdate = (enabled: boolean) => {
|
||||
// Ensure all required fields are present
|
||||
const updatedConfig = {
|
||||
enabled,
|
||||
customPrompt: terminalConfig.customPrompt,
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID,
|
||||
customAliases: terminalConfig.customAliases,
|
||||
customEnvVars: terminalConfig.customEnvVars,
|
||||
rcFileVersion: TERMINAL_RC_FILE_VERSION,
|
||||
};
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{ terminalConfig: updatedConfig },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled',
|
||||
{
|
||||
description: enabled
|
||||
? 'New terminals will use custom prompts'
|
||||
: '.automaker/terminal/ will be cleaned up',
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnvVars(
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
}, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setShowEnableConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
applyEnabledUpdate(false);
|
||||
};
|
||||
|
||||
const handleUpdateConfig = (updates: Partial<typeof terminalConfig>) => {
|
||||
const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID;
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{
|
||||
terminalConfig: {
|
||||
...terminalConfig,
|
||||
...updates,
|
||||
promptTheme: nextPromptTheme,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleEnvVarsUpdate = (envVarsObject: Record<string, string>) => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
envVarUpdateTimeoutRef.current = setTimeout(() => {
|
||||
handleUpdateConfig({ customEnvVars: envVarsObject });
|
||||
}, ENV_VAR_UPDATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handlePromptThemeChange = (themeId: string) => {
|
||||
if (themeId === PROMPT_THEME_CUSTOM_ID) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = getPromptThemePreset(themeId as TerminalPromptTheme);
|
||||
if (!preset) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateConfig({
|
||||
...preset.config,
|
||||
promptTheme: preset.id,
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setLocalEnvVars([...localEnvVars, createEnvVarEntry()]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: string) => {
|
||||
const newVars = localEnvVars.filter((envVar) => envVar.id !== id);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Update settings
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => {
|
||||
const newVars = localEnvVars.map((envVar) =>
|
||||
envVar.id === id ? { ...envVar, [field]: newValue } : envVar
|
||||
);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Validate and update settings (only if key is valid)
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
// Only include vars with valid keys (alphanumeric + underscore)
|
||||
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
|
||||
<Wand2 className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Custom Terminal Configurations
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Generate custom shell prompts that automatically sync with your app theme. Opt-in feature
|
||||
that creates configs in .automaker/terminal/ without modifying your existing RC files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Enable Custom Configurations</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create theme-synced shell configs in .automaker/terminal/
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={terminalConfig.enabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
|
||||
{terminalConfig.enabled && (
|
||||
<>
|
||||
{/* Info Box */}
|
||||
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>How it works:</strong> Custom configs are applied to new terminals only.
|
||||
Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
|
||||
see changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override default shell prompt with themed version
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.customPrompt}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ customPrompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{terminalConfig.customPrompt && (
|
||||
<>
|
||||
{/* Prompt Format */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Theme (Oh My Posh)</Label>
|
||||
<Select value={activePromptThemeId} onValueChange={handlePromptThemeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROMPT_THEME_CUSTOM_ID}>
|
||||
<div className="space-y-0.5">
|
||||
<div>Custom</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Hand-tuned configuration
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{PROMPT_THEME_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
<div className="space-y-0.5">
|
||||
<div>{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isOmpTheme && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>{promptThemePreset?.label ?? 'Oh My Posh theme'}</strong> uses the
|
||||
oh-my-posh CLI for rendering. Ensure it's installed for the full theme.
|
||||
Prompt format and segment toggles are ignored while an OMP theme is selected.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Format</Label>
|
||||
<Select
|
||||
value={terminalConfig.promptFormat}
|
||||
onValueChange={(value: 'standard' | 'minimal' | 'powerline' | 'starship') =>
|
||||
handleUpdateConfig({ promptFormat: value })
|
||||
}
|
||||
disabled={isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
<div className="space-y-0.5">
|
||||
<div>Standard</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
[user@host] ~/path (main*) $
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="minimal">
|
||||
<div className="space-y-0.5">
|
||||
<div>Minimal</div>
|
||||
<div className="text-xs text-muted-foreground">~/path (main*) $</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="powerline">
|
||||
<div className="space-y-0.5">
|
||||
<div>Powerline</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="starship">
|
||||
<div className="space-y-0.5">
|
||||
<div>Starship-Inspired</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
user@host in ~/path on main*
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show Git Branch</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitBranch}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitBranch: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">*</span>
|
||||
<Label className="text-sm">Show Git Status (dirty indicator)</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitStatus: checked })}
|
||||
disabled={!terminalConfig.showGitBranch || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt Segments */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show User & Host</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showUserHost}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showUserHost: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">~/</span>
|
||||
<Label className="text-sm">Show Path</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showPath}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showPath: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">⏱</span>
|
||||
<Label className="text-sm">Show Time</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showTime}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showTime: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">✗</span>
|
||||
<Label className="text-sm">Show Exit Status</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showExitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showExitStatus: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||
<Select
|
||||
value={terminalConfig.pathStyle}
|
||||
onValueChange={(value: 'full' | 'short' | 'basename') =>
|
||||
handleUpdateConfig({ pathStyle: value })
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Full</SelectItem>
|
||||
<SelectItem value="short">Short</SelectItem>
|
||||
<SelectItem value="basename">Basename</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Depth</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={PATH_DEPTH_MIN}
|
||||
max={PATH_DEPTH_MAX}
|
||||
value={terminalConfig.pathDepth}
|
||||
onChange={(event) =>
|
||||
handleUpdateConfig({
|
||||
pathDepth: clampPathDepth(Number(event.target.value) || 0),
|
||||
})
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Preview</Label>
|
||||
<PromptPreview
|
||||
format={terminalConfig.promptFormat}
|
||||
theme={theme}
|
||||
showGitBranch={terminalConfig.showGitBranch}
|
||||
showGitStatus={terminalConfig.showGitStatus}
|
||||
showUserHost={terminalConfig.showUserHost}
|
||||
showPath={terminalConfig.showPath}
|
||||
pathStyle={terminalConfig.pathStyle}
|
||||
pathDepth={terminalConfig.pathDepth}
|
||||
showTime={terminalConfig.showTime}
|
||||
showExitStatus={terminalConfig.showExitStatus}
|
||||
isOmpTheme={isOmpTheme}
|
||||
promptThemeLabel={promptThemePreset?.label}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Aliases</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add shell aliases (one per line, e.g., alias ll='ls -la')
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={terminalConfig.customAliases}
|
||||
onChange={(e) => handleUpdateConfig({ customAliases: e.target.value })}
|
||||
placeholder="# Custom aliases alias gs='git status' alias ll='ls -la' alias ..='cd ..'"
|
||||
className="font-mono text-sm h-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Environment Variables */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">
|
||||
Custom Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add custom env vars (alphanumeric + underscore only)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addEnvVar} className="h-8 gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localEnvVars.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{localEnvVars.map((envVar) => (
|
||||
<div key={envVar.id} className="flex gap-2 items-start">
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'key', e.target.value)}
|
||||
placeholder="VAR_NAME"
|
||||
className={cn(
|
||||
'font-mono text-sm flex-1',
|
||||
envVar.key &&
|
||||
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(envVar.key) &&
|
||||
'border-destructive'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="font-mono text-sm flex-[2]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showEnableConfirm}
|
||||
onOpenChange={setShowEnableConfirm}
|
||||
title="Enable custom terminal configurations"
|
||||
description="Automaker will generate per-project shell configuration files for your terminal."
|
||||
icon={Info}
|
||||
confirmText="Enable"
|
||||
onConfirm={() => applyEnabledUpdate(true)}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Creates shell config files in `.automaker/terminal/`</li>
|
||||
<li>Applies prompts and colors that match your app theme</li>
|
||||
<li>Leaves your existing `~/.bashrc` and `~/.zshrc` untouched</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
New terminal sessions will use the custom prompt; existing sessions are unchanged.
|
||||
</p>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { TerminalConfigSection } from './terminal-config-section';
|
||||
|
||||
export function TerminalSection() {
|
||||
const {
|
||||
@@ -53,253 +54,258 @@ export function TerminalSection() {
|
||||
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree
|
||||
menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TerminalConfigSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ test.describe('Edit Feature', () => {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, originalDescription);
|
||||
await confirmAddFeature(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await expect(async () => {
|
||||
@@ -88,7 +89,7 @@ test.describe('Edit Feature', () => {
|
||||
hasText: originalDescription,
|
||||
});
|
||||
expect(await featureCard.count()).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
|
||||
// Get the feature ID from the card
|
||||
const featureCard = page
|
||||
|
||||
BIN
docs/pr/terminal-omp.png
Normal file
BIN
docs/pr/terminal-omp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
632
docs/terminal-custom-configs-plan.md
Normal file
632
docs/terminal-custom-configs-plan.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# Implementation Plan: Custom Terminal Configurations with Theme Synchronization
|
||||
|
||||
## Overview
|
||||
|
||||
Implement custom shell configuration files (.bashrc, .zshrc) that automatically sync with Automaker's 40 themes, providing a seamless terminal experience where prompt colors match the app theme. This is an **opt-in feature** that creates configs in `.automaker/terminal/` without modifying user's existing RC files.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **RC Generator** (`libs/platform/src/rc-generator.ts`) - NEW
|
||||
- Template-based generation for bash/zsh/sh
|
||||
- Theme-to-ANSI color mapping from hex values
|
||||
- Git info integration (branch, dirty status)
|
||||
- Prompt format templates (standard, minimal, powerline, starship-inspired)
|
||||
|
||||
2. **RC File Manager** (`libs/platform/src/rc-file-manager.ts`) - NEW
|
||||
- File I/O for `.automaker/terminal/` directory
|
||||
- Version checking and regeneration logic
|
||||
- Path resolution for different shells
|
||||
|
||||
3. **Terminal Service** (`apps/server/src/services/terminal-service.ts`) - MODIFY
|
||||
- Inject BASH_ENV/ZDOTDIR environment variables when spawning PTY
|
||||
- Hook for theme change regeneration
|
||||
- Backwards compatible (no change when disabled)
|
||||
|
||||
4. **Settings Schema** (`libs/types/src/settings.ts`) - MODIFY
|
||||
- Add `terminalConfig` to GlobalSettings and ProjectSettings
|
||||
- Include enable toggle, prompt format, git info toggles, custom aliases/env vars
|
||||
|
||||
5. **Settings UI** (`apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`) - NEW
|
||||
- Enable/disable toggle with explanation
|
||||
- Prompt format selector (4 formats)
|
||||
- Git info toggles (branch/status)
|
||||
- Custom aliases textarea
|
||||
- Custom env vars key-value editor
|
||||
- Live preview panel showing example prompt
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
.automaker/terminal/
|
||||
├── bashrc.sh # Bash config (sourced via BASH_ENV)
|
||||
├── zshrc.zsh # Zsh config (via ZDOTDIR)
|
||||
├── common.sh # Shared functions (git prompt, etc.)
|
||||
├── themes/
|
||||
│ ├── dark.sh # Theme-specific color exports (40 files)
|
||||
│ ├── dracula.sh
|
||||
│ ├── nord.sh
|
||||
│ └── ... (38 more)
|
||||
├── version.txt # RC file format version (for migrations)
|
||||
└── user-custom.sh # User's additional customizations (optional)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create RC Generator Package
|
||||
|
||||
**File**: `libs/platform/src/rc-generator.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
// Main generation functions
|
||||
export function generateBashrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateZshrc(theme: ThemeMode, config: TerminalConfig): string;
|
||||
export function generateCommonFunctions(): string;
|
||||
export function generateThemeColors(theme: ThemeMode): string;
|
||||
|
||||
// Color mapping
|
||||
export function hexToXterm256(hex: string): number;
|
||||
export function getThemeANSIColors(terminalTheme: TerminalTheme): ANSIColors;
|
||||
```
|
||||
|
||||
**Templates**:
|
||||
|
||||
- Source user's original ~/.bashrc or ~/.zshrc first
|
||||
- Load theme colors from `themes/${AUTOMAKER_THEME}.sh`
|
||||
- Set custom PS1/PROMPT only if `AUTOMAKER_CUSTOM_PROMPT=true`
|
||||
- Include git prompt function: `automaker_git_prompt()`
|
||||
|
||||
**Example bashrc.sh template**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Automaker Terminal Configuration v1.0
|
||||
|
||||
# Source user's original bashrc first
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
source "${BASH_SOURCE%/*}/common.sh"
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
PS1="\[$COLOR_USER\]\u@\h\[$COLOR_RESET\] "
|
||||
PS1="$PS1\[$COLOR_PATH\]\w\[$COLOR_RESET\]"
|
||||
PS1="$PS1\$(automaker_git_prompt) "
|
||||
PS1="$PS1\[$COLOR_PROMPT\]\$\[$COLOR_RESET\] "
|
||||
fi
|
||||
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "${BASH_SOURCE%/*}/user-custom.sh" ]; then
|
||||
source "${BASH_SOURCE%/*}/user-custom.sh"
|
||||
fi
|
||||
```
|
||||
|
||||
**Color Mapping Algorithm**:
|
||||
|
||||
1. Get hex colors from `apps/ui/src/config/terminal-themes.ts` (TerminalTheme interface)
|
||||
2. Convert hex to RGB
|
||||
3. Map to closest xterm-256 color code using Euclidean distance in RGB space
|
||||
4. Generate ANSI escape codes: `\[\e[38;5;{code}m\]` for foreground
|
||||
|
||||
### Step 2: Create RC File Manager
|
||||
|
||||
**File**: `libs/platform/src/rc-file-manager.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
```typescript
|
||||
export async function ensureTerminalDir(projectPath: string): Promise<void>;
|
||||
export async function writeRcFiles(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<void>;
|
||||
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string;
|
||||
export async function checkRcFileVersion(projectPath: string): Promise<number | null>;
|
||||
export async function needsRegeneration(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<boolean>;
|
||||
```
|
||||
|
||||
**File Operations**:
|
||||
|
||||
- Create `.automaker/terminal/` if doesn't exist
|
||||
- Write RC files with 0644 permissions
|
||||
- Write theme color files (40 themes × 1 file each)
|
||||
- Create version.txt with format version (currently "11")
|
||||
- Support atomic writes (write to temp, then rename)
|
||||
|
||||
### Step 3: Add Settings Schema
|
||||
|
||||
**File**: `libs/types/src/settings.ts`
|
||||
|
||||
**Add to GlobalSettings** (around line 842):
|
||||
|
||||
```typescript
|
||||
/** Terminal configuration settings */
|
||||
terminalConfig?: {
|
||||
/** Enable custom terminal configurations (default: false) */
|
||||
enabled: boolean;
|
||||
|
||||
/** Enable custom prompt (default: true when enabled) */
|
||||
customPrompt: boolean;
|
||||
|
||||
/** Prompt format template */
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
/** Prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Show git branch in prompt (default: true) */
|
||||
showGitBranch: boolean;
|
||||
|
||||
/** Show git status dirty indicator (default: true) */
|
||||
showGitStatus: boolean;
|
||||
|
||||
/** Show user and host in prompt (default: true) */
|
||||
showUserHost: boolean;
|
||||
|
||||
/** Show path in prompt (default: true) */
|
||||
showPath: boolean;
|
||||
|
||||
/** Path display style */
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Limit path depth (0 = full path) */
|
||||
pathDepth: number;
|
||||
|
||||
/** Show current time in prompt (default: false) */
|
||||
showTime: boolean;
|
||||
|
||||
/** Show last command exit status when non-zero (default: false) */
|
||||
showExitStatus: boolean;
|
||||
|
||||
/** User-provided custom aliases (multiline string) */
|
||||
customAliases: string;
|
||||
|
||||
/** User-provided custom env vars */
|
||||
customEnvVars: Record<string, string>;
|
||||
|
||||
/** RC file format version (for migration) */
|
||||
rcFileVersion?: number;
|
||||
};
|
||||
```
|
||||
|
||||
**Add to ProjectSettings**:
|
||||
|
||||
```typescript
|
||||
/** Project-specific terminal config overrides */
|
||||
terminalConfig?: {
|
||||
/** Override global enabled setting */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Override prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
|
||||
/** Override showing user/host */
|
||||
showUserHost?: boolean;
|
||||
|
||||
/** Override showing path */
|
||||
showPath?: boolean;
|
||||
|
||||
/** Override path style */
|
||||
pathStyle?: 'full' | 'short' | 'basename';
|
||||
|
||||
/** Override path depth (0 = full path) */
|
||||
pathDepth?: number;
|
||||
|
||||
/** Override showing time */
|
||||
showTime?: boolean;
|
||||
|
||||
/** Override showing exit status */
|
||||
showExitStatus?: boolean;
|
||||
|
||||
/** Project-specific custom aliases */
|
||||
customAliases?: string;
|
||||
|
||||
/** Project-specific env vars */
|
||||
customEnvVars?: Record<string, string>;
|
||||
|
||||
/** Custom welcome message for this project */
|
||||
welcomeMessage?: string;
|
||||
};
|
||||
```
|
||||
|
||||
**Defaults**:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_TERMINAL_CONFIG = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: 'custom' as const,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: 0,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
rcFileVersion: 11,
|
||||
};
|
||||
```
|
||||
|
||||
**Oh My Posh Themes**:
|
||||
|
||||
- When `promptTheme` starts with `omp-` and `oh-my-posh` is available, the generated RC files will
|
||||
initialize oh-my-posh with the selected theme name.
|
||||
- If oh-my-posh is not installed, the prompt falls back to the Automaker-built prompt format.
|
||||
- `POSH_THEMES_PATH` is exported to the standard user themes directory so themes resolve offline.
|
||||
|
||||
### Step 4: Modify Terminal Service
|
||||
|
||||
**File**: `apps/server/src/services/terminal-service.ts`
|
||||
|
||||
**Modification Point**: In `createSession()` method, around line 335-344 where `env` object is built.
|
||||
|
||||
**Add before PTY spawn**:
|
||||
|
||||
```typescript
|
||||
// Get terminal config from settings
|
||||
const terminalConfig = await this.settingsService?.getGlobalSettings();
|
||||
const projectSettings = options.projectPath
|
||||
? await this.settingsService?.getProjectSettings(options.projectPath)
|
||||
: null;
|
||||
|
||||
const effectiveTerminalConfig = {
|
||||
...terminalConfig?.terminalConfig,
|
||||
...projectSettings?.terminalConfig,
|
||||
};
|
||||
|
||||
if (effectiveTerminalConfig?.enabled) {
|
||||
// Ensure RC files are up to date
|
||||
const currentTheme = terminalConfig?.theme || 'dark';
|
||||
await ensureRcFilesUpToDate(options.projectPath || cwd, currentTheme, effectiveTerminalConfig);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = path.basename(shell).toLowerCase();
|
||||
|
||||
if (shellName.includes('bash')) {
|
||||
env.BASH_ENV = getRcFilePath(options.projectPath || cwd, 'bash');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName.includes('zsh')) {
|
||||
env.ZDOTDIR = path.join(options.projectPath || cwd, '.automaker', 'terminal');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === 'sh') {
|
||||
env.ENV = getRcFilePath(options.projectPath || cwd, 'sh');
|
||||
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
|
||||
env.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Add new method for theme changes**:
|
||||
|
||||
```typescript
|
||||
async onThemeChange(projectPath: string, newTheme: ThemeMode): Promise<void> {
|
||||
const globalSettings = await this.settingsService?.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
|
||||
if (terminalConfig?.enabled) {
|
||||
// Regenerate RC files with new theme
|
||||
await writeRcFiles(projectPath, newTheme, terminalConfig);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create Settings UI
|
||||
|
||||
**File**: `apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`
|
||||
|
||||
**Component Structure**:
|
||||
|
||||
```typescript
|
||||
export function TerminalConfigSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Enable Toggle with Warning */}
|
||||
<div>
|
||||
<Label>Custom Terminal Configurations</Label>
|
||||
<Switch checked={enabled} onCheckedChange={handleToggle} />
|
||||
<p>Creates custom shell configs in .automaker/terminal/</p>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* Custom Prompt Toggle */}
|
||||
<Switch checked={customPrompt} />
|
||||
|
||||
{/* Prompt Format Selector */}
|
||||
<Select value={promptFormat} onValueChange={setPromptFormat}>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="powerline">Powerline</option>
|
||||
<option value="starship">Starship-Inspired</option>
|
||||
</Select>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<Switch checked={showGitBranch} label="Show Git Branch" />
|
||||
<Switch checked={showGitStatus} label="Show Git Status" />
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<Textarea
|
||||
value={customAliases}
|
||||
placeholder="# Custom aliases\nalias ll='ls -la'"
|
||||
/>
|
||||
|
||||
{/* Custom Env Vars */}
|
||||
<KeyValueEditor
|
||||
value={customEnvVars}
|
||||
onChange={setCustomEnvVars}
|
||||
/>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
<PromptPreview
|
||||
format={promptFormat}
|
||||
theme={effectiveTheme}
|
||||
gitBranch={showGitBranch ? 'main' : null}
|
||||
gitDirty={showGitStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Preview Component**:
|
||||
Shows example prompt like: `[user@host] ~/projects/automaker (main*) $`
|
||||
Updates instantly when theme or format changes.
|
||||
|
||||
### Step 6: Theme Change Hook
|
||||
|
||||
**File**: `apps/server/src/routes/settings.ts`
|
||||
|
||||
**Hook into theme update endpoint**:
|
||||
|
||||
```typescript
|
||||
// After updating theme in settings
|
||||
if (oldTheme !== newTheme) {
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects;
|
||||
for (const project of projects) {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
if (projectSettings.terminalConfig?.enabled !== false) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shell Configuration Strategy
|
||||
|
||||
### Bash (via BASH_ENV)
|
||||
|
||||
- Set `BASH_ENV=/path/to/.automaker/terminal/bashrc.sh`
|
||||
- BASH_ENV is loaded for all shells (interactive and non-interactive)
|
||||
- User's ~/.bashrc is sourced first within our bashrc.sh
|
||||
- No need for `--rcfile` flag (which would skip ~/.bashrc)
|
||||
|
||||
### Zsh (via ZDOTDIR)
|
||||
|
||||
- Set `ZDOTDIR=/path/to/.automaker/terminal/`
|
||||
- Create `.zshrc` symlink: `zshrc.zsh`
|
||||
- User's ~/.zshrc is sourced within our zshrc.zsh
|
||||
- Zsh's canonical configuration directory mechanism
|
||||
|
||||
### Sh (via ENV)
|
||||
|
||||
- Set `ENV=/path/to/.automaker/terminal/common.sh`
|
||||
- POSIX shell standard environment variable
|
||||
- Minimal prompt (POSIX sh doesn't support advanced prompts)
|
||||
|
||||
## Prompt Formats
|
||||
|
||||
### 1. Standard
|
||||
|
||||
```
|
||||
[user@host] ~/path/to/project (main*) $
|
||||
```
|
||||
|
||||
### 2. Minimal
|
||||
|
||||
```
|
||||
~/project (main*) $
|
||||
```
|
||||
|
||||
### 3. Powerline (Unicode box-drawing)
|
||||
|
||||
```
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
└─$
|
||||
```
|
||||
|
||||
### 4. Starship-Inspired
|
||||
|
||||
```
|
||||
user@host in ~/path on main*
|
||||
❯
|
||||
```
|
||||
|
||||
## Theme Synchronization
|
||||
|
||||
### On Initial Enable
|
||||
|
||||
1. User toggles "Enable Custom Terminal Configs"
|
||||
2. Show confirmation dialog explaining what will happen
|
||||
3. Generate RC files for current theme
|
||||
4. Set `rcFileVersion: 11` in settings
|
||||
|
||||
### On Theme Change
|
||||
|
||||
1. User changes app theme in settings
|
||||
2. Settings API detects theme change
|
||||
3. Call `terminalService.onThemeChange()` for each project
|
||||
4. Regenerate theme color files (`.automaker/terminal/themes/`)
|
||||
5. Existing terminals keep old theme (expected behavior)
|
||||
6. New terminals use new theme
|
||||
|
||||
### On Disable
|
||||
|
||||
1. User toggles off "Enable Custom Terminal Configs"
|
||||
2. Delete `.automaker/terminal/` directory
|
||||
3. New terminals spawn without custom env vars
|
||||
4. Existing terminals continue with current config until restarted
|
||||
|
||||
## Critical Files
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/server/src/services/terminal-service.ts` - Add env var injection logic at line ~335-344
|
||||
2. `/home/dhanush/Projects/automaker/libs/types/src/settings.ts` - Add terminalConfig to GlobalSettings (~line 842) and ProjectSettings
|
||||
3. `/home/dhanush/Projects/automaker/apps/server/src/routes/settings.ts` - Add theme change hook
|
||||
|
||||
### Files to Create
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/libs/platform/src/rc-generator.ts` - RC file generation logic
|
||||
2. `/home/dhanush/Projects/automaker/libs/platform/src/rc-file-manager.ts` - File I/O and path resolution
|
||||
3. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx` - Settings UI
|
||||
4. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx` - Live preview component
|
||||
|
||||
### Files to Read
|
||||
|
||||
1. `/home/dhanush/Projects/automaker/apps/ui/src/config/terminal-themes.ts` - Source of theme hex colors for ANSI mapping
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `rc-generator.test.ts`: Test template generation for all 40 themes
|
||||
- `rc-file-manager.test.ts`: Test file I/O and version checking
|
||||
- `terminal-service.test.ts`: Test env var injection with mocked PTY spawn
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Enable custom configs in settings
|
||||
- Change theme and verify new terminals use new colors
|
||||
- Add custom aliases and verify they work in terminal
|
||||
- Test all 4 prompt formats
|
||||
- Test disable flow (files removed, terminals work normally)
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Test on macOS with zsh
|
||||
- [ ] Test on Linux with bash
|
||||
- [ ] Test all 40 themes have correct colors
|
||||
- [ ] Test git prompt in repo vs non-repo directories
|
||||
- [ ] Test custom aliases execution
|
||||
- [ ] Test custom env vars available
|
||||
- [ ] Test project-specific overrides
|
||||
- [ ] Test disable/re-enable flow
|
||||
|
||||
## Verification
|
||||
|
||||
### End-to-End Test
|
||||
|
||||
1. Enable custom terminal configs in settings
|
||||
2. Set prompt format to "powerline"
|
||||
3. Add custom alias: `alias gs='git status'`
|
||||
4. Change theme to "dracula"
|
||||
5. Open new terminal
|
||||
6. Verify:
|
||||
- Prompt uses powerline format with theme colors
|
||||
- Git branch shows if in repo
|
||||
- `gs` alias works
|
||||
- User's ~/.bashrc still loaded (test with known alias from user's file)
|
||||
7. Change theme to "nord"
|
||||
8. Open new terminal
|
||||
9. Verify prompt colors changed to match nord theme
|
||||
10. Disable custom configs
|
||||
11. Verify `.automaker/terminal/` deleted
|
||||
12. Open new terminal
|
||||
13. Verify standard prompt without custom config
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- ✅ Feature can be enabled/disabled in settings
|
||||
- ✅ RC files generated in `.automaker/terminal/`
|
||||
- ✅ Prompt colors match theme (all 40 themes)
|
||||
- ✅ Git branch/status shown in prompt
|
||||
- ✅ Custom aliases work
|
||||
- ✅ Custom env vars available
|
||||
- ✅ User's original ~/.bashrc or ~/.zshrc still loads
|
||||
- ✅ Theme changes regenerate color files
|
||||
- ✅ Works on Mac (zsh) and Linux (bash)
|
||||
- ✅ No breaking changes to existing terminal functionality
|
||||
|
||||
## Security & Safety
|
||||
|
||||
### File Permissions
|
||||
|
||||
- RC files: 0644 (user read/write, others read)
|
||||
- Directory: 0755 (user rwx, others rx)
|
||||
- No secrets in RC files
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
- Escape special characters in custom aliases
|
||||
- Validate env var names (alphanumeric + underscore only)
|
||||
- No eval of user-provided code
|
||||
- Shell escaping for all user inputs
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
- Feature disabled by default
|
||||
- Existing terminals unaffected when disabled
|
||||
- User's original RC files always sourced first
|
||||
- Easy rollback (just disable and delete files)
|
||||
|
||||
## Branch Creation
|
||||
|
||||
Per PR workflow in DEVELOPMENT_WORKFLOW.md:
|
||||
|
||||
1. Create feature branch: `git checkout -b feature/custom-terminal-configs`
|
||||
2. Implement changes following this plan
|
||||
3. Test thoroughly
|
||||
4. Merge upstream RC before shipping: `git merge upstream/v0.14.0rc --no-edit`
|
||||
5. Push to origin: `git push -u origin feature/custom-terminal-configs`
|
||||
6. Create PR targeting `main` branch
|
||||
|
||||
## Documentation
|
||||
|
||||
After implementation, create comprehensive documentation at:
|
||||
`/home/dhanush/Projects/automaker/docs/terminal-custom-configs.md`
|
||||
|
||||
**Documentation should cover**:
|
||||
|
||||
- Feature overview and benefits
|
||||
- How to enable custom terminal configs
|
||||
- Prompt format options with examples
|
||||
- Custom aliases and env vars
|
||||
- Theme synchronization behavior
|
||||
- Troubleshooting common issues
|
||||
- How to disable the feature
|
||||
- Technical details for contributors
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- Week 1: Core infrastructure (RC generator, file manager, settings schema)
|
||||
- Week 2: Terminal service integration, theme sync
|
||||
- Week 3: Settings UI, preview component
|
||||
- Week 4: Testing, documentation, polish
|
||||
|
||||
Total: ~4 weeks for complete implementation
|
||||
@@ -186,3 +186,37 @@ export {
|
||||
findTerminalById,
|
||||
openInExternalTerminal,
|
||||
} from './terminal.js';
|
||||
|
||||
// RC Generator - Shell configuration file generation
|
||||
export {
|
||||
hexToXterm256,
|
||||
getThemeANSIColors,
|
||||
generateBashrc,
|
||||
generateZshrc,
|
||||
generateCommonFunctions,
|
||||
generateThemeColors,
|
||||
getShellName,
|
||||
type TerminalConfig,
|
||||
type TerminalTheme,
|
||||
type ANSIColors,
|
||||
} from './rc-generator.js';
|
||||
|
||||
// RC File Manager - Shell configuration file I/O
|
||||
export {
|
||||
RC_FILE_VERSION,
|
||||
getTerminalDir,
|
||||
getThemesDir,
|
||||
getRcFilePath,
|
||||
ensureTerminalDir,
|
||||
checkRcFileVersion,
|
||||
needsRegeneration,
|
||||
writeAllThemeFiles,
|
||||
writeThemeFile,
|
||||
writeRcFiles,
|
||||
ensureRcFilesUpToDate,
|
||||
deleteTerminalDir,
|
||||
ensureUserCustomFile,
|
||||
} from './rc-file-manager.js';
|
||||
|
||||
// Terminal Theme Colors - Raw theme color data for all 40 themes
|
||||
export { terminalThemeColors, getTerminalThemeColors } from './terminal-theme-colors.js';
|
||||
|
||||
308
libs/platform/src/rc-file-manager.ts
Normal file
308
libs/platform/src/rc-file-manager.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* RC File Manager - Manage shell configuration files in .automaker/terminal/
|
||||
*
|
||||
* This module handles file I/O operations for generating and managing shell RC files,
|
||||
* including version checking and regeneration logic.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import {
|
||||
generateBashrc,
|
||||
generateZshrc,
|
||||
generateCommonFunctions,
|
||||
generateThemeColors,
|
||||
type TerminalConfig,
|
||||
type TerminalTheme,
|
||||
} from './rc-generator.js';
|
||||
|
||||
/**
|
||||
* Current RC file format version
|
||||
*/
|
||||
export const RC_FILE_VERSION = 11;
|
||||
|
||||
const RC_SIGNATURE_FILENAME = 'config.sha256';
|
||||
|
||||
/**
|
||||
* Get the terminal directory path
|
||||
*/
|
||||
export function getTerminalDir(projectPath: string): string {
|
||||
return path.join(projectPath, '.automaker', 'terminal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the themes directory path
|
||||
*/
|
||||
export function getThemesDir(projectPath: string): string {
|
||||
return path.join(getTerminalDir(projectPath), 'themes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RC file path for specific shell
|
||||
*/
|
||||
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
switch (shell) {
|
||||
case 'bash':
|
||||
return path.join(terminalDir, 'bashrc.sh');
|
||||
case 'zsh':
|
||||
return path.join(terminalDir, '.zshrc'); // Zsh looks for .zshrc in ZDOTDIR
|
||||
case 'sh':
|
||||
return path.join(terminalDir, 'common.sh');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure terminal directory exists
|
||||
*/
|
||||
export async function ensureTerminalDir(projectPath: string): Promise<void> {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
|
||||
await fs.mkdir(terminalDir, { recursive: true, mode: 0o755 });
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write RC file with atomic write (write to temp, then rename)
|
||||
*/
|
||||
async function atomicWriteFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
mode: number = 0o644
|
||||
): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
function sortObjectKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sortObjectKeys(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const sortedEntries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const sortedObject: Record<string, unknown> = {};
|
||||
for (const [key, entryValue] of sortedEntries) {
|
||||
sortedObject[key] = sortObjectKeys(entryValue);
|
||||
}
|
||||
return sortedObject;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildConfigSignature(theme: ThemeMode, config: TerminalConfig): string {
|
||||
const payload = { theme, config: sortObjectKeys(config) };
|
||||
const serializedPayload = JSON.stringify(payload);
|
||||
return createHash('sha256').update(serializedPayload).digest('hex');
|
||||
}
|
||||
|
||||
async function readSignatureFile(projectPath: string): Promise<string | null> {
|
||||
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
|
||||
try {
|
||||
const signature = await fs.readFile(signaturePath, 'utf8');
|
||||
return signature.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSignatureFile(projectPath: string, signature: string): Promise<void> {
|
||||
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
|
||||
await atomicWriteFile(signaturePath, `${signature}\n`, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current RC file version
|
||||
*/
|
||||
export async function checkRcFileVersion(projectPath: string): Promise<number | null> {
|
||||
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
|
||||
try {
|
||||
const content = await fs.readFile(versionPath, 'utf8');
|
||||
const version = parseInt(content.trim(), 10);
|
||||
return isNaN(version) ? null : version;
|
||||
} catch (error) {
|
||||
return null; // File doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write version file
|
||||
*/
|
||||
async function writeVersionFile(projectPath: string, version: number): Promise<void> {
|
||||
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
|
||||
await atomicWriteFile(versionPath, `${version}\n`, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if RC files need regeneration
|
||||
*/
|
||||
export async function needsRegeneration(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig
|
||||
): Promise<boolean> {
|
||||
const currentVersion = await checkRcFileVersion(projectPath);
|
||||
|
||||
// Regenerate if version doesn't match or files don't exist
|
||||
if (currentVersion !== RC_FILE_VERSION) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expectedSignature = buildConfigSignature(theme, config);
|
||||
const existingSignature = await readSignatureFile(projectPath);
|
||||
if (!existingSignature || existingSignature !== expectedSignature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if critical files exist
|
||||
const bashrcPath = getRcFilePath(projectPath, 'bash');
|
||||
const zshrcPath = getRcFilePath(projectPath, 'zsh');
|
||||
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
|
||||
const themeFilePath = path.join(getThemesDir(projectPath), `${theme}.sh`);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.access(bashrcPath),
|
||||
fs.access(zshrcPath),
|
||||
fs.access(commonPath),
|
||||
fs.access(themeFilePath),
|
||||
]);
|
||||
return false; // All files exist
|
||||
} catch {
|
||||
return true; // Some files are missing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all theme color files (all 40 themes)
|
||||
*/
|
||||
export async function writeAllThemeFiles(
|
||||
projectPath: string,
|
||||
terminalThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
const themeEntries = Object.entries(terminalThemes);
|
||||
await Promise.all(
|
||||
themeEntries.map(async ([themeName, theme]) => {
|
||||
const themeFilePath = path.join(themesDir, `${themeName}.sh`);
|
||||
const content = generateThemeColors(theme);
|
||||
await atomicWriteFile(themeFilePath, content, 0o644);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single theme color file
|
||||
*/
|
||||
export async function writeThemeFile(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
themeColors: TerminalTheme
|
||||
): Promise<void> {
|
||||
const themesDir = getThemesDir(projectPath);
|
||||
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
const themeFilePath = path.join(themesDir, `${theme}.sh`);
|
||||
const content = generateThemeColors(themeColors);
|
||||
await atomicWriteFile(themeFilePath, content, 0o644);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all RC files
|
||||
*/
|
||||
export async function writeRcFiles(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig,
|
||||
themeColors: TerminalTheme,
|
||||
allThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
await ensureTerminalDir(projectPath);
|
||||
|
||||
// Write common functions file
|
||||
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
|
||||
const commonContent = generateCommonFunctions(config);
|
||||
await atomicWriteFile(commonPath, commonContent, 0o644);
|
||||
|
||||
// Write bashrc
|
||||
const bashrcPath = getRcFilePath(projectPath, 'bash');
|
||||
const bashrcContent = generateBashrc(themeColors, config);
|
||||
await atomicWriteFile(bashrcPath, bashrcContent, 0o644);
|
||||
|
||||
// Write zshrc
|
||||
const zshrcPath = getRcFilePath(projectPath, 'zsh');
|
||||
const zshrcContent = generateZshrc(themeColors, config);
|
||||
await atomicWriteFile(zshrcPath, zshrcContent, 0o644);
|
||||
|
||||
// Write all theme files (40 themes)
|
||||
await writeAllThemeFiles(projectPath, allThemes);
|
||||
|
||||
// Write version file
|
||||
await writeVersionFile(projectPath, RC_FILE_VERSION);
|
||||
|
||||
// Write config signature for change detection
|
||||
const signature = buildConfigSignature(theme, config);
|
||||
await writeSignatureFile(projectPath, signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure RC files are up to date
|
||||
*/
|
||||
export async function ensureRcFilesUpToDate(
|
||||
projectPath: string,
|
||||
theme: ThemeMode,
|
||||
config: TerminalConfig,
|
||||
themeColors: TerminalTheme,
|
||||
allThemes: Record<ThemeMode, TerminalTheme>
|
||||
): Promise<void> {
|
||||
const needsRegen = await needsRegeneration(projectPath, theme, config);
|
||||
if (needsRegen) {
|
||||
await writeRcFiles(projectPath, theme, config, themeColors, allThemes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete terminal directory (for disable flow)
|
||||
*/
|
||||
export async function deleteTerminalDir(projectPath: string): Promise<void> {
|
||||
const terminalDir = getTerminalDir(projectPath);
|
||||
try {
|
||||
await fs.rm(terminalDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user-custom.sh placeholder if it doesn't exist
|
||||
*/
|
||||
export async function ensureUserCustomFile(projectPath: string): Promise<void> {
|
||||
const userCustomPath = path.join(getTerminalDir(projectPath), 'user-custom.sh');
|
||||
try {
|
||||
await fs.access(userCustomPath);
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
const content = `#!/bin/sh
|
||||
# Automaker User Customizations
|
||||
# Add your custom shell configuration here
|
||||
# This file will not be overwritten by Automaker
|
||||
|
||||
# Example: Add custom aliases
|
||||
# alias myalias='command'
|
||||
|
||||
# Example: Add custom environment variables
|
||||
# export MY_VAR="value"
|
||||
`;
|
||||
await atomicWriteFile(userCustomPath, content, 0o644);
|
||||
}
|
||||
}
|
||||
972
libs/platform/src/rc-generator.ts
Normal file
972
libs/platform/src/rc-generator.ts
Normal file
@@ -0,0 +1,972 @@
|
||||
/**
|
||||
* RC Generator - Generate shell configuration files for custom terminal prompts
|
||||
*
|
||||
* This module generates bash/zsh/sh configuration files that sync with Automaker's themes,
|
||||
* providing custom prompts with theme-matched colors while preserving user's existing RC files.
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Terminal configuration options
|
||||
*/
|
||||
export interface TerminalConfig {
|
||||
enabled: boolean;
|
||||
customPrompt: boolean;
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
customAliases: string;
|
||||
customEnvVars: Record<string, string>;
|
||||
rcFileVersion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal theme colors (hex values)
|
||||
*/
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ANSI color codes for shell prompts
|
||||
*/
|
||||
export interface ANSIColors {
|
||||
user: string;
|
||||
host: string;
|
||||
path: string;
|
||||
gitBranch: string;
|
||||
gitDirty: string;
|
||||
prompt: string;
|
||||
reset: string;
|
||||
}
|
||||
|
||||
const STARTUP_COLOR_PRIMARY = 51;
|
||||
const STARTUP_COLOR_SECONDARY = 39;
|
||||
const STARTUP_COLOR_ACCENT = 33;
|
||||
const DEFAULT_PATH_DEPTH = 0;
|
||||
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||
const OMP_BINARY = 'oh-my-posh';
|
||||
const OMP_SHELL_BASH = 'bash';
|
||||
const OMP_SHELL_ZSH = 'zsh';
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) {
|
||||
throw new Error(`Invalid hex color: ${hex}`);
|
||||
}
|
||||
return {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Euclidean distance between two RGB colors
|
||||
*/
|
||||
function colorDistance(
|
||||
c1: { r: number; g: number; b: number },
|
||||
c2: { r: number; g: number; b: number }
|
||||
): number {
|
||||
return Math.sqrt(Math.pow(c1.r - c2.r, 2) + Math.pow(c1.g - c2.g, 2) + Math.pow(c1.b - c2.b, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm-256 color palette (simplified - standard colors + 6x6x6 RGB cube + grayscale)
|
||||
*/
|
||||
const XTERM_256_PALETTE: Array<{ r: number; g: number; b: number }> = [];
|
||||
|
||||
// Standard colors (0-15) - already handled by ANSI basic colors
|
||||
// RGB cube (16-231): 6x6x6 cube with levels 0, 95, 135, 175, 215, 255
|
||||
const levels = [0, 95, 135, 175, 215, 255];
|
||||
for (let r = 0; r < 6; r++) {
|
||||
for (let g = 0; g < 6; g++) {
|
||||
for (let b = 0; b < 6; b++) {
|
||||
XTERM_256_PALETTE.push({ r: levels[r], g: levels[g], b: levels[b] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grayscale (232-255): 24 shades from #080808 to #eeeeee
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const gray = 8 + i * 10;
|
||||
XTERM_256_PALETTE.push({ r: gray, g: gray, b: gray });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to closest xterm-256 color code
|
||||
*/
|
||||
export function hexToXterm256(hex: string): number {
|
||||
const rgb = hexToRgb(hex);
|
||||
let closestIndex = 16; // Start from RGB cube
|
||||
let minDistance = Infinity;
|
||||
|
||||
XTERM_256_PALETTE.forEach((color, index) => {
|
||||
const distance = colorDistance(rgb, color);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestIndex = index + 16; // Offset by 16 (standard colors)
|
||||
}
|
||||
});
|
||||
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ANSI color codes from theme colors
|
||||
*/
|
||||
export function getThemeANSIColors(theme: TerminalTheme): ANSIColors {
|
||||
return {
|
||||
user: `\\[\\e[38;5;${hexToXterm256(theme.cyan)}m\\]`,
|
||||
host: `\\[\\e[38;5;${hexToXterm256(theme.blue)}m\\]`,
|
||||
path: `\\[\\e[38;5;${hexToXterm256(theme.yellow)}m\\]`,
|
||||
gitBranch: `\\[\\e[38;5;${hexToXterm256(theme.magenta)}m\\]`,
|
||||
gitDirty: `\\[\\e[38;5;${hexToXterm256(theme.red)}m\\]`,
|
||||
prompt: `\\[\\e[38;5;${hexToXterm256(theme.green)}m\\]`,
|
||||
reset: '\\[\\e[0m\\]',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape shell special characters in user input
|
||||
*/
|
||||
function shellEscape(str: string): string {
|
||||
return str.replace(/([`$\\"])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate environment variable name
|
||||
*/
|
||||
function isValidEnvVarName(name: string): boolean {
|
||||
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
function stripPromptEscapes(ansiColor: string): string {
|
||||
return ansiColor.replace(/\\\[/g, '').replace(/\\\]/g, '');
|
||||
}
|
||||
|
||||
function normalizePathStyle(
|
||||
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||
): TerminalConfig['pathStyle'] {
|
||||
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||
return pathStyle;
|
||||
}
|
||||
return DEFAULT_PATH_STYLE;
|
||||
}
|
||||
|
||||
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||
const depth =
|
||||
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||
}
|
||||
|
||||
function generateOhMyPoshInit(
|
||||
shell: typeof OMP_SHELL_BASH | typeof OMP_SHELL_ZSH,
|
||||
fallback: string
|
||||
) {
|
||||
const themeVar = `$${OMP_THEME_ENV_VAR}`;
|
||||
const initCommand = `${OMP_BINARY} init ${shell} --config`;
|
||||
return `if [ -n "${themeVar}" ] && command -v ${OMP_BINARY} >/dev/null 2>&1; then
|
||||
automaker_omp_theme="$(automaker_resolve_omp_theme)"
|
||||
if [ -n "$automaker_omp_theme" ]; then
|
||||
eval "$(${initCommand} "$automaker_omp_theme")"
|
||||
else
|
||||
${fallback}
|
||||
fi
|
||||
else
|
||||
${fallback}
|
||||
fi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate common shell functions (git prompt, etc.)
|
||||
*/
|
||||
export function generateCommonFunctions(config: TerminalConfig): string {
|
||||
const gitPrompt = config.showGitBranch
|
||||
? `
|
||||
automaker_git_prompt() {
|
||||
local branch=""
|
||||
local dirty=""
|
||||
|
||||
# Check if we're in a git repository
|
||||
if git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
# Get current branch name
|
||||
branch=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null)
|
||||
|
||||
${
|
||||
config.showGitStatus
|
||||
? `
|
||||
# Check if working directory is dirty
|
||||
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
dirty="*"
|
||||
fi
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
if [ -n "$branch" ]; then
|
||||
echo -n " ($branch$dirty)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
`
|
||||
: `
|
||||
automaker_git_prompt() {
|
||||
# Git prompt disabled
|
||||
echo -n ""
|
||||
}
|
||||
`;
|
||||
|
||||
return `#!/bin/sh
|
||||
# Automaker Terminal Configuration - Common Functions v1.0
|
||||
|
||||
${gitPrompt}
|
||||
|
||||
AUTOMAKER_INFO_UNKNOWN="Unknown"
|
||||
AUTOMAKER_BANNER_LABEL_WIDTH=12
|
||||
AUTOMAKER_BYTES_PER_KIB=1024
|
||||
AUTOMAKER_KIB_PER_MIB=1024
|
||||
AUTOMAKER_MIB_PER_GIB=1024
|
||||
AUTOMAKER_COLOR_PRIMARY="\\033[38;5;${STARTUP_COLOR_PRIMARY}m"
|
||||
AUTOMAKER_COLOR_SECONDARY="\\033[38;5;${STARTUP_COLOR_SECONDARY}m"
|
||||
AUTOMAKER_COLOR_ACCENT="\\033[38;5;${STARTUP_COLOR_ACCENT}m"
|
||||
AUTOMAKER_COLOR_RESET="\\033[0m"
|
||||
AUTOMAKER_SHOW_TIME="${config.showTime === true ? 'true' : 'false'}"
|
||||
AUTOMAKER_SHOW_EXIT_STATUS="${config.showExitStatus === true ? 'true' : 'false'}"
|
||||
AUTOMAKER_SHOW_USER_HOST="${config.showUserHost === false ? 'false' : 'true'}"
|
||||
AUTOMAKER_SHOW_PATH="${config.showPath === false ? 'false' : 'true'}"
|
||||
AUTOMAKER_PATH_STYLE="${normalizePathStyle(config.pathStyle)}"
|
||||
AUTOMAKER_PATH_DEPTH=${normalizePathDepth(config.pathDepth)}
|
||||
automaker_default_themes_dir="\${XDG_DATA_HOME:-\$HOME/.local/share}/oh-my-posh/themes"
|
||||
if [ -z "$POSH_THEMES_PATH" ] || [ ! -d "$POSH_THEMES_PATH" ]; then
|
||||
POSH_THEMES_PATH="$automaker_default_themes_dir"
|
||||
fi
|
||||
export POSH_THEMES_PATH
|
||||
|
||||
automaker_resolve_omp_theme() {
|
||||
automaker_theme_name="$AUTOMAKER_OMP_THEME"
|
||||
if [ -z "$automaker_theme_name" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -f "$automaker_theme_name" ]; then
|
||||
printf '%s' "$automaker_theme_name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
automaker_themes_base="\${POSH_THEMES_PATH%/}"
|
||||
if [ -n "$automaker_themes_base" ]; then
|
||||
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.json" ]; then
|
||||
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.json"
|
||||
return 0
|
||||
fi
|
||||
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.yaml" ]; then
|
||||
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.yaml"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
automaker_command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
automaker_get_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
if [ -n "$PRETTY_NAME" ]; then
|
||||
echo "$PRETTY_NAME"
|
||||
return
|
||||
fi
|
||||
if [ -n "$NAME" ] && [ -n "$VERSION" ]; then
|
||||
echo "$NAME $VERSION"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if automaker_command_exists sw_vers; then
|
||||
echo "$(sw_vers -productName) $(sw_vers -productVersion)"
|
||||
return
|
||||
fi
|
||||
|
||||
uname -s 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_uptime() {
|
||||
if automaker_command_exists uptime; then
|
||||
if uptime -p >/dev/null 2>&1; then
|
||||
uptime -p
|
||||
return
|
||||
fi
|
||||
uptime 2>/dev/null | sed 's/.*up \\([^,]*\\).*/\\1/' || uptime 2>/dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_cpu() {
|
||||
if automaker_command_exists lscpu; then
|
||||
lscpu | sed -n 's/Model name:[[:space:]]*//p' | head -n 1
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists sysctl; then
|
||||
sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null
|
||||
return
|
||||
fi
|
||||
|
||||
uname -m 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_memory() {
|
||||
if automaker_command_exists free; then
|
||||
free -h | awk '/Mem:/ {print $3 " / " $2}'
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists vm_stat; then
|
||||
local page_size
|
||||
local pages_free
|
||||
local pages_active
|
||||
local pages_inactive
|
||||
local pages_wired
|
||||
local pages_total
|
||||
page_size=$(vm_stat | awk '/page size of/ {print $8}')
|
||||
pages_free=$(vm_stat | awk '/Pages free/ {print $3}' | tr -d '.')
|
||||
pages_active=$(vm_stat | awk '/Pages active/ {print $3}' | tr -d '.')
|
||||
pages_inactive=$(vm_stat | awk '/Pages inactive/ {print $3}' | tr -d '.')
|
||||
pages_wired=$(vm_stat | awk '/Pages wired down/ {print $4}' | tr -d '.')
|
||||
pages_total=$((pages_free + pages_active + pages_inactive + pages_wired))
|
||||
awk -v total="$pages_total" -v free="$pages_free" -v size="$page_size" \
|
||||
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
|
||||
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
|
||||
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
|
||||
'BEGIN {
|
||||
total_gb = total * size / bytes_kib / kib_mib / mib_gib;
|
||||
used_gb = (total - free) * size / bytes_kib / kib_mib / mib_gib;
|
||||
printf("%.1f GB / %.1f GB", used_gb, total_gb);
|
||||
}'
|
||||
return
|
||||
fi
|
||||
|
||||
if automaker_command_exists sysctl; then
|
||||
local total_bytes
|
||||
total_bytes=$(sysctl -n hw.memsize 2>/dev/null)
|
||||
if [ -n "$total_bytes" ]; then
|
||||
awk -v total="$total_bytes" \
|
||||
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
|
||||
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
|
||||
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
|
||||
'BEGIN {printf("%.1f GB", total / bytes_kib / kib_mib / mib_gib)}'
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_disk() {
|
||||
if automaker_command_exists df; then
|
||||
df -h / 2>/dev/null | awk 'NR==2 {print $3 " / " $2}'
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_get_ip() {
|
||||
if automaker_command_exists hostname; then
|
||||
local ip_addr
|
||||
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$ip_addr" ]; then
|
||||
echo "$ip_addr"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if automaker_command_exists ipconfig; then
|
||||
local ip_addr
|
||||
ip_addr=$(ipconfig getifaddr en0 2>/dev/null)
|
||||
if [ -n "$ip_addr" ]; then
|
||||
echo "$ip_addr"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$AUTOMAKER_INFO_UNKNOWN"
|
||||
}
|
||||
|
||||
automaker_trim_path_depth() {
|
||||
local path="$1"
|
||||
local depth="$2"
|
||||
if [ -z "$depth" ] || [ "$depth" -le 0 ]; then
|
||||
echo "$path"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "$path" | awk -v depth="$depth" -F/ '{
|
||||
prefix=""
|
||||
start=1
|
||||
if ($1=="") { prefix="/"; start=2 }
|
||||
else if ($1=="~") { prefix="~/"; start=2 }
|
||||
n=NF
|
||||
if (n < start) {
|
||||
if (prefix=="/") { print "/" }
|
||||
else if (prefix=="~/") { print "~" }
|
||||
else { print $0 }
|
||||
next
|
||||
}
|
||||
segCount = n - start + 1
|
||||
d = depth
|
||||
if (d > segCount) { d = segCount }
|
||||
out=""
|
||||
for (i = n - d + 1; i <= n; i++) {
|
||||
out = out (out=="" ? "" : "/") $i
|
||||
}
|
||||
if (prefix=="/") {
|
||||
if (out=="") { out="/" } else { out="/" out }
|
||||
} else if (prefix=="~/") {
|
||||
if (out=="") { out="~" } else { out="~/" out }
|
||||
}
|
||||
print out
|
||||
}'
|
||||
}
|
||||
|
||||
automaker_shorten_path() {
|
||||
local path="$1"
|
||||
echo "$path" | awk -F/ '{
|
||||
prefix=""
|
||||
start=1
|
||||
if ($1=="") { prefix="/"; start=2 }
|
||||
else if ($1=="~") { prefix="~/"; start=2 }
|
||||
n=NF
|
||||
if (n < start) {
|
||||
if (prefix=="/") { print "/" }
|
||||
else if (prefix=="~/") { print "~" }
|
||||
else { print $0 }
|
||||
next
|
||||
}
|
||||
out=""
|
||||
for (i = start; i <= n; i++) {
|
||||
seg = $i
|
||||
if (i < n && length(seg) > 0) { seg = substr(seg, 1, 1) }
|
||||
out = out (out=="" ? "" : "/") seg
|
||||
}
|
||||
if (prefix=="/") { out="/" out }
|
||||
else if (prefix=="~/") { out="~/" out }
|
||||
print out
|
||||
}'
|
||||
}
|
||||
|
||||
automaker_prompt_path() {
|
||||
if [ "$AUTOMAKER_SHOW_PATH" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local current_path="$PWD"
|
||||
if [ -n "$HOME" ] && [ "\${current_path#"$HOME"}" != "$current_path" ]; then
|
||||
current_path="~\${current_path#$HOME}"
|
||||
fi
|
||||
|
||||
if [ "$AUTOMAKER_PATH_DEPTH" -gt 0 ]; then
|
||||
current_path=$(automaker_trim_path_depth "$current_path" "$AUTOMAKER_PATH_DEPTH")
|
||||
fi
|
||||
|
||||
case "$AUTOMAKER_PATH_STYLE" in
|
||||
basename)
|
||||
if [ "$current_path" = "/" ] || [ "$current_path" = "~" ]; then
|
||||
echo -n "$current_path"
|
||||
else
|
||||
echo -n "\${current_path##*/}"
|
||||
fi
|
||||
;;
|
||||
short)
|
||||
echo -n "$(automaker_shorten_path "$current_path")"
|
||||
;;
|
||||
full|*)
|
||||
echo -n "$current_path"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
automaker_prompt_time() {
|
||||
if [ "$AUTOMAKER_SHOW_TIME" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
date +%H:%M
|
||||
}
|
||||
|
||||
automaker_prompt_status() {
|
||||
automaker_last_status=$?
|
||||
if [ "$AUTOMAKER_SHOW_EXIT_STATUS" != "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$automaker_last_status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
printf "✗ %s" "$automaker_last_status"
|
||||
}
|
||||
|
||||
automaker_show_banner() {
|
||||
local label_width="$AUTOMAKER_BANNER_LABEL_WIDTH"
|
||||
local logo_line_1=" █▀▀█ █ █ ▀▀█▀▀ █▀▀█ █▀▄▀█ █▀▀█ █ █ █▀▀ █▀▀█ "
|
||||
local logo_line_2=" █▄▄█ █ █ █ █ █ █ ▀ █ █▄▄█ █▀▄ █▀▀ █▄▄▀ "
|
||||
local logo_line_3=" ▀ ▀ ▀▀▀ ▀ ▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀ "
|
||||
local accent_color="\${AUTOMAKER_COLOR_PRIMARY}"
|
||||
local secondary_color="\${AUTOMAKER_COLOR_SECONDARY}"
|
||||
local tertiary_color="\${AUTOMAKER_COLOR_ACCENT}"
|
||||
local label_color="\${AUTOMAKER_COLOR_SECONDARY}"
|
||||
local reset_color="\${AUTOMAKER_COLOR_RESET}"
|
||||
|
||||
printf "%b%s%b\n" "$accent_color" "$logo_line_1" "$reset_color"
|
||||
printf "%b%s%b\n" "$secondary_color" "$logo_line_2" "$reset_color"
|
||||
printf "%b%s%b\n" "$tertiary_color" "$logo_line_3" "$reset_color"
|
||||
printf "\n"
|
||||
|
||||
local shell_name="\${SHELL##*/}"
|
||||
if [ -z "$shell_name" ]; then
|
||||
shell_name=$(basename "$0" 2>/dev/null || echo "shell")
|
||||
fi
|
||||
local user_host="\${USER:-unknown}@$(hostname 2>/dev/null || echo unknown)"
|
||||
printf "%b%s%b\n" "$label_color" "$user_host" "$reset_color"
|
||||
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "OS:" "$reset_color" "$(automaker_get_os)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Uptime:" "$reset_color" "$(automaker_get_uptime)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Shell:" "$reset_color" "$shell_name"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Terminal:" "$reset_color" "\${TERM_PROGRAM:-$TERM}"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "CPU:" "$reset_color" "$(automaker_get_cpu)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Memory:" "$reset_color" "$(automaker_get_memory)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Disk:" "$reset_color" "$(automaker_get_disk)"
|
||||
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Local IP:" "$reset_color" "$(automaker_get_ip)"
|
||||
printf "\n"
|
||||
}
|
||||
|
||||
automaker_show_banner_once() {
|
||||
case "$-" in
|
||||
*i*) ;;
|
||||
*) return ;;
|
||||
esac
|
||||
|
||||
if [ "$AUTOMAKER_BANNER_SHOWN" = "true" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
automaker_show_banner
|
||||
export AUTOMAKER_BANNER_SHOWN="true"
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate prompt based on format
|
||||
*/
|
||||
function generatePrompt(
|
||||
format: TerminalConfig['promptFormat'],
|
||||
colors: ANSIColors,
|
||||
config: TerminalConfig
|
||||
): string {
|
||||
const userHostSegment = config.showUserHost
|
||||
? `${colors.user}\\u${colors.reset}@${colors.host}\\h${colors.reset}`
|
||||
: '';
|
||||
const pathSegment = config.showPath
|
||||
? `${colors.path}\\$(automaker_prompt_path)${colors.reset}`
|
||||
: '';
|
||||
const gitSegment = config.showGitBranch
|
||||
? `${colors.gitBranch}\\$(automaker_git_prompt)${colors.reset}`
|
||||
: '';
|
||||
const timeSegment = config.showTime
|
||||
? `${colors.gitBranch}[\\$(automaker_prompt_time)]${colors.reset}`
|
||||
: '';
|
||||
const statusSegment = config.showExitStatus
|
||||
? `${colors.gitDirty}\\$(automaker_prompt_status)${colors.reset}`
|
||||
: '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
const minimalSegments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${minimalSegments ? `${minimalSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineCoreSegments = [
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment ? `[${pathSegment}]` : '',
|
||||
].filter((segment) => segment.length > 0);
|
||||
const powerlineCore = powerlineCoreSegments.join('─');
|
||||
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
const powerlineLine = [powerlineCore, powerlineExtras]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="┌─${powerlineLine}\\n└─${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
let starshipLine = '';
|
||||
if (userHostSegment && pathSegment) {
|
||||
starshipLine = `${userHostSegment} in ${pathSegment}`;
|
||||
} else {
|
||||
starshipLine = [userHostSegment, pathSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
}
|
||||
if (gitSegment) {
|
||||
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
|
||||
}
|
||||
const starshipSegments = [timeSegment, starshipLine, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${starshipSegments}\\n${colors.prompt}❯${colors.reset} "`;
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
const standardSegments = [
|
||||
timeSegment,
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment,
|
||||
gitSegment,
|
||||
statusSegment,
|
||||
]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PS1="${standardSegments ? `${standardSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Zsh prompt based on format
|
||||
*/
|
||||
function generateZshPrompt(
|
||||
format: TerminalConfig['promptFormat'],
|
||||
colors: ANSIColors,
|
||||
config: TerminalConfig
|
||||
): string {
|
||||
// Convert bash-style \u, \h, \w to zsh-style %n, %m, %~
|
||||
// Remove bash-style escaping \[ \] (not needed in zsh)
|
||||
const zshColors = {
|
||||
user: colors.user
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
host: colors.host
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
path: colors.path
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
gitBranch: colors.gitBranch
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
gitDirty: colors.gitDirty
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
prompt: colors.prompt
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
reset: colors.reset
|
||||
.replace(/\\[\[\]\\e]/g, '')
|
||||
.replace(/\\e/g, '%{')
|
||||
.replace(/m\\]/g, 'm%}'),
|
||||
};
|
||||
|
||||
const userHostSegment = config.showUserHost
|
||||
? `[${zshColors.user}%n${zshColors.reset}@${zshColors.host}%m${zshColors.reset}]`
|
||||
: '';
|
||||
const pathSegment = config.showPath
|
||||
? `${zshColors.path}$(automaker_prompt_path)${zshColors.reset}`
|
||||
: '';
|
||||
const gitSegment = config.showGitBranch
|
||||
? `${zshColors.gitBranch}$(automaker_git_prompt)${zshColors.reset}`
|
||||
: '';
|
||||
const timeSegment = config.showTime
|
||||
? `${zshColors.gitBranch}[$(automaker_prompt_time)]${zshColors.reset}`
|
||||
: '';
|
||||
const statusSegment = config.showExitStatus
|
||||
? `${zshColors.gitDirty}$(automaker_prompt_status)${zshColors.reset}`
|
||||
: '';
|
||||
const segments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment].filter(
|
||||
(segment) => segment.length > 0
|
||||
);
|
||||
const inlineSegments = segments.join(' ');
|
||||
const inlineWithSpace = inlineSegments ? `${inlineSegments} ` : '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
return `PROMPT="${inlineWithSpace}${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineCoreSegments = [
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment ? `[${pathSegment}]` : '',
|
||||
].filter((segment) => segment.length > 0);
|
||||
const powerlineCore = powerlineCoreSegments.join('─');
|
||||
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
const powerlineLine = [powerlineCore, powerlineExtras]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="┌─${powerlineLine}
|
||||
└─${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
let starshipLine = '';
|
||||
if (userHostSegment && pathSegment) {
|
||||
starshipLine = `${userHostSegment} in ${pathSegment}`;
|
||||
} else {
|
||||
starshipLine = [userHostSegment, pathSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
}
|
||||
if (gitSegment) {
|
||||
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
|
||||
}
|
||||
const starshipSegments = [timeSegment, starshipLine, statusSegment]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="${starshipSegments}
|
||||
${zshColors.prompt}❯${zshColors.reset} "`;
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
const standardSegments = [
|
||||
timeSegment,
|
||||
userHostSegment ? `[${userHostSegment}]` : '',
|
||||
pathSegment,
|
||||
gitSegment,
|
||||
statusSegment,
|
||||
]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(' ');
|
||||
return `PROMPT="${standardSegments ? `${standardSegments} ` : ''}${zshColors.prompt}%#${zshColors.reset} "`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom aliases section
|
||||
*/
|
||||
function generateAliases(config: TerminalConfig): string {
|
||||
if (!config.customAliases) return '';
|
||||
|
||||
// Escape and validate aliases
|
||||
const escapedAliases = shellEscape(config.customAliases);
|
||||
return `
|
||||
# Custom aliases
|
||||
${escapedAliases}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate custom environment variables section
|
||||
*/
|
||||
function generateEnvVars(config: TerminalConfig): string {
|
||||
if (!config.customEnvVars || Object.keys(config.customEnvVars).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const validEnvVars = Object.entries(config.customEnvVars)
|
||||
.filter(([name]) => isValidEnvVarName(name))
|
||||
.map(([name, value]) => `export ${name}="${shellEscape(value)}"`)
|
||||
.join('\n');
|
||||
|
||||
return validEnvVars
|
||||
? `
|
||||
# Custom environment variables
|
||||
${validEnvVars}
|
||||
`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate bashrc configuration
|
||||
*/
|
||||
export function generateBashrc(theme: TerminalTheme, config: TerminalConfig): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const promptLine = generatePrompt(config.promptFormat, colors, config);
|
||||
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_BASH, promptLine);
|
||||
|
||||
return `#!/bin/bash
|
||||
# Automaker Terminal Configuration v1.0
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# Source user's original bashrc first (preserves user configuration)
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
source "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
if [ -f "\${BASH_SOURCE%/*}/common.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/common.sh"
|
||||
fi
|
||||
|
||||
# Show Automaker banner on shell start
|
||||
if command -v automaker_show_banner_once >/dev/null 2>&1; then
|
||||
automaker_show_banner_once
|
||||
fi
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
${promptInitializer}
|
||||
fi
|
||||
${generateAliases(config)}${generateEnvVars(config)}
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "\${BASH_SOURCE%/*}/user-custom.sh" ]; then
|
||||
source "\${BASH_SOURCE%/*}/user-custom.sh"
|
||||
fi
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate zshrc configuration
|
||||
*/
|
||||
export function generateZshrc(theme: TerminalTheme, config: TerminalConfig): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const promptLine = generateZshPrompt(config.promptFormat, colors, config);
|
||||
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_ZSH, promptLine);
|
||||
|
||||
return `#!/bin/zsh
|
||||
# Automaker Terminal Configuration v1.0
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# Source user's original zshrc first (preserves user configuration)
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
source "$HOME/.zshrc"
|
||||
fi
|
||||
|
||||
# Load Automaker theme colors
|
||||
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh"
|
||||
fi
|
||||
|
||||
# Load common functions (git prompt)
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/common.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/common.sh"
|
||||
fi
|
||||
|
||||
# Enable command substitution in PROMPT
|
||||
setopt PROMPT_SUBST
|
||||
|
||||
# Show Automaker banner on shell start
|
||||
if command -v automaker_show_banner_once >/dev/null 2>&1; then
|
||||
automaker_show_banner_once
|
||||
fi
|
||||
|
||||
# Set custom prompt (only if enabled)
|
||||
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
|
||||
${promptInitializer}
|
||||
fi
|
||||
${generateAliases(config)}${generateEnvVars(config)}
|
||||
# Load user customizations (if exists)
|
||||
if [ -f "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh" ]; then
|
||||
source "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh"
|
||||
fi
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate theme color exports for shell
|
||||
*/
|
||||
export function generateThemeColors(theme: TerminalTheme): string {
|
||||
const colors = getThemeANSIColors(theme);
|
||||
const rawColors = {
|
||||
user: stripPromptEscapes(colors.user),
|
||||
host: stripPromptEscapes(colors.host),
|
||||
path: stripPromptEscapes(colors.path),
|
||||
gitBranch: stripPromptEscapes(colors.gitBranch),
|
||||
gitDirty: stripPromptEscapes(colors.gitDirty),
|
||||
prompt: stripPromptEscapes(colors.prompt),
|
||||
reset: stripPromptEscapes(colors.reset),
|
||||
};
|
||||
|
||||
return `#!/bin/sh
|
||||
# Automaker Theme Colors
|
||||
# This file is automatically generated - manual edits will be overwritten
|
||||
|
||||
# ANSI color codes for prompt
|
||||
export COLOR_USER="${colors.user}"
|
||||
export COLOR_HOST="${colors.host}"
|
||||
export COLOR_PATH="${colors.path}"
|
||||
export COLOR_GIT_BRANCH="${colors.gitBranch}"
|
||||
export COLOR_GIT_DIRTY="${colors.gitDirty}"
|
||||
export COLOR_PROMPT="${colors.prompt}"
|
||||
export COLOR_RESET="${colors.reset}"
|
||||
|
||||
# ANSI color codes for banner output (no prompt escapes)
|
||||
export COLOR_USER_RAW="${rawColors.user}"
|
||||
export COLOR_HOST_RAW="${rawColors.host}"
|
||||
export COLOR_PATH_RAW="${rawColors.path}"
|
||||
export COLOR_GIT_BRANCH_RAW="${rawColors.gitBranch}"
|
||||
export COLOR_GIT_DIRTY_RAW="${rawColors.gitDirty}"
|
||||
export COLOR_PROMPT_RAW="${rawColors.prompt}"
|
||||
export COLOR_RESET_RAW="${rawColors.reset}"
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell name from file extension
|
||||
*/
|
||||
export function getShellName(rcFile: string): 'bash' | 'zsh' | 'sh' | null {
|
||||
if (rcFile.endsWith('.sh') && rcFile.includes('bashrc')) return 'bash';
|
||||
if (rcFile.endsWith('.zsh') || rcFile.endsWith('.zshrc')) return 'zsh';
|
||||
if (rcFile.endsWith('.sh')) return 'sh';
|
||||
return null;
|
||||
}
|
||||
@@ -750,6 +750,9 @@ export function electronUserDataWriteFileSync(
|
||||
throw new Error('[SystemPaths] Electron userData path not initialized');
|
||||
}
|
||||
const fullPath = path.join(electronUserDataPath, relativePath);
|
||||
// Ensure parent directory exists (may not exist on first launch)
|
||||
const dir = path.dirname(fullPath);
|
||||
fsSync.mkdirSync(dir, { recursive: true });
|
||||
fsSync.writeFileSync(fullPath, data, options);
|
||||
}
|
||||
|
||||
|
||||
468
libs/platform/src/terminal-theme-colors.ts
Normal file
468
libs/platform/src/terminal-theme-colors.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Terminal Theme Colors - Color definitions for all 40 themes
|
||||
*
|
||||
* This module contains only the raw color data for terminal themes,
|
||||
* extracted from the UI package to avoid circular dependencies.
|
||||
* These colors are used by both UI (xterm.js) and server (RC file generation).
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from './rc-generator.js';
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#0a0a0a',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#1e1e1e',
|
||||
red: '#f44747',
|
||||
green: '#6a9955',
|
||||
yellow: '#dcdcaa',
|
||||
blue: '#569cd6',
|
||||
magenta: '#c586c0',
|
||||
cyan: '#4ec9b0',
|
||||
white: '#d4d4d4',
|
||||
brightBlack: '#808080',
|
||||
brightRed: '#f44747',
|
||||
brightGreen: '#6a9955',
|
||||
brightYellow: '#dcdcaa',
|
||||
brightBlue: '#569cd6',
|
||||
brightMagenta: '#c586c0',
|
||||
brightCyan: '#4ec9b0',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Light theme
|
||||
const lightTheme: TerminalTheme = {
|
||||
background: '#ffffff',
|
||||
foreground: '#383a42',
|
||||
cursor: '#383a42',
|
||||
cursorAccent: '#ffffff',
|
||||
selectionBackground: '#add6ff',
|
||||
black: '#383a42',
|
||||
red: '#e45649',
|
||||
green: '#50a14f',
|
||||
yellow: '#c18401',
|
||||
blue: '#4078f2',
|
||||
magenta: '#a626a4',
|
||||
cyan: '#0184bc',
|
||||
white: '#fafafa',
|
||||
brightBlack: '#4f525e',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Retro / Cyberpunk theme - neon green on black
|
||||
const retroTheme: TerminalTheme = {
|
||||
background: '#000000',
|
||||
foreground: '#39ff14',
|
||||
cursor: '#39ff14',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#39ff14',
|
||||
selectionForeground: '#000000',
|
||||
black: '#000000',
|
||||
red: '#ff0055',
|
||||
green: '#39ff14',
|
||||
yellow: '#ffff00',
|
||||
blue: '#00ffff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#39ff14',
|
||||
brightBlack: '#555555',
|
||||
brightRed: '#ff5555',
|
||||
brightGreen: '#55ff55',
|
||||
brightYellow: '#ffff55',
|
||||
brightBlue: '#55ffff',
|
||||
brightMagenta: '#ff55ff',
|
||||
brightCyan: '#55ffff',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Dracula theme
|
||||
const draculaTheme: TerminalTheme = {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: '#44475a',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Nord theme
|
||||
const nordTheme: TerminalTheme = {
|
||||
background: '#2e3440',
|
||||
foreground: '#d8dee9',
|
||||
cursor: '#d8dee9',
|
||||
cursorAccent: '#2e3440',
|
||||
selectionBackground: '#434c5e',
|
||||
black: '#3b4252',
|
||||
red: '#bf616a',
|
||||
green: '#a3be8c',
|
||||
yellow: '#ebcb8b',
|
||||
blue: '#81a1c1',
|
||||
magenta: '#b48ead',
|
||||
cyan: '#88c0d0',
|
||||
white: '#e5e9f0',
|
||||
brightBlack: '#4c566a',
|
||||
brightRed: '#bf616a',
|
||||
brightGreen: '#a3be8c',
|
||||
brightYellow: '#ebcb8b',
|
||||
brightBlue: '#81a1c1',
|
||||
brightMagenta: '#b48ead',
|
||||
brightCyan: '#8fbcbb',
|
||||
brightWhite: '#eceff4',
|
||||
};
|
||||
|
||||
// Monokai theme
|
||||
const monokaiTheme: TerminalTheme = {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#272822',
|
||||
selectionBackground: '#49483e',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5',
|
||||
};
|
||||
|
||||
// Tokyo Night theme
|
||||
const tokyonightTheme: TerminalTheme = {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
};
|
||||
|
||||
// Solarized Dark theme
|
||||
const solarizedTheme: TerminalTheme = {
|
||||
background: '#002b36',
|
||||
foreground: '#93a1a1',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: '#002b36',
|
||||
selectionBackground: '#073642',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#002b36',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3',
|
||||
};
|
||||
|
||||
// Gruvbox Dark theme
|
||||
const gruvboxTheme: TerminalTheme = {
|
||||
background: '#282828',
|
||||
foreground: '#ebdbb2',
|
||||
cursor: '#ebdbb2',
|
||||
cursorAccent: '#282828',
|
||||
selectionBackground: '#504945',
|
||||
black: '#282828',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#a89984',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#fb4934',
|
||||
brightGreen: '#b8bb26',
|
||||
brightYellow: '#fabd2f',
|
||||
brightBlue: '#83a598',
|
||||
brightMagenta: '#d3869b',
|
||||
brightCyan: '#8ec07c',
|
||||
brightWhite: '#ebdbb2',
|
||||
};
|
||||
|
||||
// Catppuccin Mocha theme
|
||||
const catppuccinTheme: TerminalTheme = {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#45475a',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#cba6f7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#cba6f7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
};
|
||||
|
||||
// One Dark theme
|
||||
const onedarkTheme: TerminalTheme = {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: '#3e4451',
|
||||
black: '#282c34',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Synthwave '84 theme
|
||||
const synthwaveTheme: TerminalTheme = {
|
||||
background: '#262335',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ff7edb',
|
||||
cursorAccent: '#262335',
|
||||
selectionBackground: '#463465',
|
||||
black: '#262335',
|
||||
red: '#fe4450',
|
||||
green: '#72f1b8',
|
||||
yellow: '#fede5d',
|
||||
blue: '#03edf9',
|
||||
magenta: '#ff7edb',
|
||||
cyan: '#03edf9',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#614d85',
|
||||
brightRed: '#fe4450',
|
||||
brightGreen: '#72f1b8',
|
||||
brightYellow: '#f97e72',
|
||||
brightBlue: '#03edf9',
|
||||
brightMagenta: '#ff7edb',
|
||||
brightCyan: '#03edf9',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
// Red theme
|
||||
const redTheme: TerminalTheme = {
|
||||
background: '#1a0a0a',
|
||||
foreground: '#c8b0b0',
|
||||
cursor: '#ff4444',
|
||||
cursorAccent: '#1a0a0a',
|
||||
selectionBackground: '#5a2020',
|
||||
black: '#2a1010',
|
||||
red: '#ff4444',
|
||||
green: '#6a9a6a',
|
||||
yellow: '#ccaa55',
|
||||
blue: '#6688aa',
|
||||
magenta: '#aa5588',
|
||||
cyan: '#558888',
|
||||
white: '#b0a0a0',
|
||||
brightBlack: '#6a4040',
|
||||
brightRed: '#ff6666',
|
||||
brightGreen: '#88bb88',
|
||||
brightYellow: '#ddbb66',
|
||||
brightBlue: '#88aacc',
|
||||
brightMagenta: '#cc77aa',
|
||||
brightCyan: '#77aaaa',
|
||||
brightWhite: '#d0c0c0',
|
||||
};
|
||||
|
||||
// Cream theme
|
||||
const creamTheme: TerminalTheme = {
|
||||
background: '#f5f3ee',
|
||||
foreground: '#5a4a3a',
|
||||
cursor: '#9d6b53',
|
||||
cursorAccent: '#f5f3ee',
|
||||
selectionBackground: '#d4c4b0',
|
||||
black: '#5a4a3a',
|
||||
red: '#c85a4f',
|
||||
green: '#7a9a6a',
|
||||
yellow: '#c9a554',
|
||||
blue: '#6b8aaa',
|
||||
magenta: '#a66a8a',
|
||||
cyan: '#5a9a8a',
|
||||
white: '#b0a090',
|
||||
brightBlack: '#8a7a6a',
|
||||
brightRed: '#e07060',
|
||||
brightGreen: '#90b080',
|
||||
brightYellow: '#e0bb70',
|
||||
brightBlue: '#80a0c0',
|
||||
brightMagenta: '#c080a0',
|
||||
brightCyan: '#70b0a0',
|
||||
brightWhite: '#d0c0b0',
|
||||
};
|
||||
|
||||
// Sunset theme
|
||||
const sunsetTheme: TerminalTheme = {
|
||||
background: '#1e1a24',
|
||||
foreground: '#f2e8dd',
|
||||
cursor: '#dd8855',
|
||||
cursorAccent: '#1e1a24',
|
||||
selectionBackground: '#3a2a40',
|
||||
black: '#1e1a24',
|
||||
red: '#dd6655',
|
||||
green: '#88bb77',
|
||||
yellow: '#ddaa66',
|
||||
blue: '#6699cc',
|
||||
magenta: '#cc7799',
|
||||
cyan: '#66ccaa',
|
||||
white: '#e8d8c8',
|
||||
brightBlack: '#4a3a50',
|
||||
brightRed: '#ee8866',
|
||||
brightGreen: '#99cc88',
|
||||
brightYellow: '#eebb77',
|
||||
brightBlue: '#88aadd',
|
||||
brightMagenta: '#dd88aa',
|
||||
brightCyan: '#88ddbb',
|
||||
brightWhite: '#f5e8dd',
|
||||
};
|
||||
|
||||
// Gray theme
|
||||
const grayTheme: TerminalTheme = {
|
||||
background: '#2a2d32',
|
||||
foreground: '#d0d0d5',
|
||||
cursor: '#8fa0c0',
|
||||
cursorAccent: '#2a2d32',
|
||||
selectionBackground: '#3a3f48',
|
||||
black: '#2a2d32',
|
||||
red: '#d87070',
|
||||
green: '#78b088',
|
||||
yellow: '#d0b060',
|
||||
blue: '#7090c0',
|
||||
magenta: '#a880b0',
|
||||
cyan: '#60a0b0',
|
||||
white: '#b0b0b8',
|
||||
brightBlack: '#606068',
|
||||
brightRed: '#e88888',
|
||||
brightGreen: '#90c8a0',
|
||||
brightYellow: '#e0c878',
|
||||
brightBlue: '#90b0d8',
|
||||
brightMagenta: '#c098c8',
|
||||
brightCyan: '#80b8c8',
|
||||
brightWhite: '#e0e0e8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme color mapping for all 40 themes
|
||||
*/
|
||||
export const terminalThemeColors: Record<ThemeMode, TerminalTheme> = {
|
||||
// Special
|
||||
system: darkTheme, // Resolved at runtime based on OS preference
|
||||
// Dark themes (16)
|
||||
dark: darkTheme,
|
||||
retro: retroTheme,
|
||||
dracula: draculaTheme,
|
||||
nord: nordTheme,
|
||||
monokai: monokaiTheme,
|
||||
tokyonight: tokyonightTheme,
|
||||
solarized: solarizedTheme,
|
||||
gruvbox: gruvboxTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
onedark: onedarkTheme,
|
||||
synthwave: synthwaveTheme,
|
||||
red: redTheme,
|
||||
sunset: sunsetTheme,
|
||||
gray: grayTheme,
|
||||
forest: gruvboxTheme, // Green-ish theme
|
||||
ocean: nordTheme, // Blue-ish theme
|
||||
ember: monokaiTheme, // Warm orange theme
|
||||
'ayu-dark': darkTheme,
|
||||
'ayu-mirage': darkTheme,
|
||||
matcha: nordTheme,
|
||||
// Light themes (16)
|
||||
light: lightTheme,
|
||||
cream: creamTheme,
|
||||
solarizedlight: lightTheme,
|
||||
github: lightTheme,
|
||||
paper: lightTheme,
|
||||
rose: lightTheme,
|
||||
mint: lightTheme,
|
||||
lavender: lightTheme,
|
||||
sand: creamTheme,
|
||||
sky: lightTheme,
|
||||
peach: creamTheme,
|
||||
snow: lightTheme,
|
||||
sepia: creamTheme,
|
||||
gruvboxlight: creamTheme,
|
||||
nordlight: lightTheme,
|
||||
blossom: lightTheme,
|
||||
'ayu-light': lightTheme,
|
||||
onelight: lightTheme,
|
||||
bluloco: lightTheme,
|
||||
feather: lightTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return terminalThemeColors[theme] || darkTheme;
|
||||
}
|
||||
@@ -140,9 +140,9 @@ const SUPPORTED_TERMINALS: TerminalDefinition[] = [
|
||||
{
|
||||
id: 'warp',
|
||||
name: 'Warp',
|
||||
cliCommand: 'warp',
|
||||
cliCommand: 'warp-cli',
|
||||
cliAliases: ['warp-terminal', 'warp'],
|
||||
macAppName: 'Warp',
|
||||
platform: 'darwin',
|
||||
},
|
||||
{
|
||||
id: 'ghostty',
|
||||
@@ -476,6 +476,11 @@ async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string
|
||||
await spawnDetached(command, [`--working-directory=${targetPath}`]);
|
||||
break;
|
||||
|
||||
case 'warp':
|
||||
// Warp: uses --cwd flag (CLI mode, not app bundle)
|
||||
await spawnDetached(command, ['--cwd', targetPath]);
|
||||
break;
|
||||
|
||||
case 'alacritty':
|
||||
// Alacritty: uses --working-directory flag
|
||||
await spawnDetached(command, ['--working-directory', targetPath]);
|
||||
|
||||
100
libs/platform/tests/rc-file-manager.test.ts
Normal file
100
libs/platform/tests/rc-file-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { needsRegeneration, writeRcFiles } from '../src/rc-file-manager';
|
||||
import { terminalThemeColors } from '../src/terminal-theme-colors';
|
||||
import type { TerminalConfig } from '../src/rc-generator';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
describe('rc-file-manager.ts', () => {
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
|
||||
const TEMP_DIR_PREFIX = 'platform-rc-files-test-';
|
||||
const PROJECT_DIR_NAME = 'test-project';
|
||||
const THEME_DARK = 'dark' as ThemeMode;
|
||||
const THEME_LIGHT = 'light' as ThemeMode;
|
||||
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
|
||||
const PROMPT_FORMAT_MINIMAL: TerminalConfig['promptFormat'] = 'minimal';
|
||||
const EMPTY_ALIASES = '';
|
||||
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
|
||||
const PATH_DEPTH_DEFAULT = 0;
|
||||
|
||||
const baseConfig: TerminalConfig = {
|
||||
enabled: true,
|
||||
customPrompt: true,
|
||||
promptFormat: PROMPT_FORMAT_STANDARD,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: PATH_STYLE_FULL,
|
||||
pathDepth: PATH_DEPTH_DEFAULT,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: EMPTY_ALIASES,
|
||||
customEnvVars: {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), TEMP_DIR_PREFIX));
|
||||
projectPath = path.join(tempDir, PROJECT_DIR_NAME);
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should not regenerate when signature matches', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, baseConfig);
|
||||
|
||||
expect(needsRegen).toBe(false);
|
||||
});
|
||||
|
||||
it('should regenerate when config changes', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const updatedConfig: TerminalConfig = {
|
||||
...baseConfig,
|
||||
promptFormat: PROMPT_FORMAT_MINIMAL,
|
||||
};
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, updatedConfig);
|
||||
|
||||
expect(needsRegen).toBe(true);
|
||||
});
|
||||
|
||||
it('should regenerate when theme changes', async () => {
|
||||
await writeRcFiles(
|
||||
projectPath,
|
||||
THEME_DARK,
|
||||
baseConfig,
|
||||
terminalThemeColors[THEME_DARK],
|
||||
terminalThemeColors
|
||||
);
|
||||
|
||||
const needsRegen = await needsRegeneration(projectPath, THEME_LIGHT, baseConfig);
|
||||
|
||||
expect(needsRegen).toBe(true);
|
||||
});
|
||||
});
|
||||
55
libs/platform/tests/rc-generator.test.ts
Normal file
55
libs/platform/tests/rc-generator.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateCommonFunctions, generateThemeColors } from '../src/rc-generator';
|
||||
import { terminalThemeColors } from '../src/terminal-theme-colors';
|
||||
import type { TerminalConfig } from '../src/rc-generator';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
|
||||
describe('rc-generator.ts', () => {
|
||||
const THEME_DARK = 'dark' as ThemeMode;
|
||||
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
|
||||
const EMPTY_ALIASES = '';
|
||||
const EMPTY_ENV_VARS = {};
|
||||
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
|
||||
const PATH_DEPTH_DEFAULT = 0;
|
||||
const EXPECTED_BANNER_FUNCTION = 'automaker_show_banner_once';
|
||||
const RAW_COLOR_PREFIX = 'export COLOR_USER_RAW=';
|
||||
const RAW_COLOR_ESCAPE_START = '\\\\[';
|
||||
const RAW_COLOR_ESCAPE_END = '\\\\]';
|
||||
const STARTUP_PRIMARY_COLOR = '38;5;51m';
|
||||
const STARTUP_SECONDARY_COLOR = '38;5;39m';
|
||||
const STARTUP_ACCENT_COLOR = '38;5;33m';
|
||||
|
||||
const baseConfig: TerminalConfig = {
|
||||
enabled: true,
|
||||
customPrompt: true,
|
||||
promptFormat: PROMPT_FORMAT_STANDARD,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: PATH_STYLE_FULL,
|
||||
pathDepth: PATH_DEPTH_DEFAULT,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: EMPTY_ALIASES,
|
||||
customEnvVars: EMPTY_ENV_VARS,
|
||||
};
|
||||
|
||||
it('includes banner functions in common shell script', () => {
|
||||
const output = generateCommonFunctions(baseConfig);
|
||||
|
||||
expect(output).toContain(EXPECTED_BANNER_FUNCTION);
|
||||
expect(output).toContain(STARTUP_PRIMARY_COLOR);
|
||||
expect(output).toContain(STARTUP_SECONDARY_COLOR);
|
||||
expect(output).toContain(STARTUP_ACCENT_COLOR);
|
||||
});
|
||||
|
||||
it('exports raw banner colors without prompt escape wrappers', () => {
|
||||
const output = generateThemeColors(terminalThemeColors[THEME_DARK]);
|
||||
const rawLine = output.split('\n').find((line) => line.startsWith(RAW_COLOR_PREFIX));
|
||||
|
||||
expect(rawLine).toBeDefined();
|
||||
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_START);
|
||||
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_END);
|
||||
});
|
||||
});
|
||||
@@ -27,14 +27,16 @@ export type { ModelAlias };
|
||||
*
|
||||
* Includes system theme and multiple color schemes organized by dark/light:
|
||||
* - System: Respects OS dark/light mode preference
|
||||
* - Dark themes (16): dark, retro, dracula, nord, monokai, tokyonight, solarized,
|
||||
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean
|
||||
* - Light themes (16): light, cream, solarizedlight, github, paper, rose, mint,
|
||||
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom
|
||||
* - Dark themes (20): dark, retro, dracula, nord, monokai, tokyonight, solarized,
|
||||
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean,
|
||||
* ember, ayu-dark, ayu-mirage, matcha
|
||||
* - Light themes (20): light, cream, solarizedlight, github, paper, rose, mint,
|
||||
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom,
|
||||
* ayu-light, onelight, bluloco, feather
|
||||
*/
|
||||
export type ThemeMode =
|
||||
| 'system'
|
||||
// Dark themes (16)
|
||||
// Dark themes (20)
|
||||
| 'dark'
|
||||
| 'retro'
|
||||
| 'dracula'
|
||||
@@ -51,7 +53,11 @@ export type ThemeMode =
|
||||
| 'gray'
|
||||
| 'forest'
|
||||
| 'ocean'
|
||||
// Light themes (16)
|
||||
| 'ember'
|
||||
| 'ayu-dark'
|
||||
| 'ayu-mirage'
|
||||
| 'matcha'
|
||||
// Light themes (20)
|
||||
| 'light'
|
||||
| 'cream'
|
||||
| 'solarizedlight'
|
||||
@@ -67,7 +73,138 @@ export type ThemeMode =
|
||||
| 'sepia'
|
||||
| 'gruvboxlight'
|
||||
| 'nordlight'
|
||||
| 'blossom';
|
||||
| 'blossom'
|
||||
| 'ayu-light'
|
||||
| 'onelight'
|
||||
| 'bluloco'
|
||||
| 'feather';
|
||||
|
||||
export type TerminalPromptTheme =
|
||||
| 'custom'
|
||||
| 'omp-1_shell'
|
||||
| 'omp-agnoster'
|
||||
| 'omp-agnoster.minimal'
|
||||
| 'omp-agnosterplus'
|
||||
| 'omp-aliens'
|
||||
| 'omp-amro'
|
||||
| 'omp-atomic'
|
||||
| 'omp-atomicBit'
|
||||
| 'omp-avit'
|
||||
| 'omp-blue-owl'
|
||||
| 'omp-blueish'
|
||||
| 'omp-bubbles'
|
||||
| 'omp-bubblesextra'
|
||||
| 'omp-bubblesline'
|
||||
| 'omp-capr4n'
|
||||
| 'omp-catppuccin'
|
||||
| 'omp-catppuccin_frappe'
|
||||
| 'omp-catppuccin_latte'
|
||||
| 'omp-catppuccin_macchiato'
|
||||
| 'omp-catppuccin_mocha'
|
||||
| 'omp-cert'
|
||||
| 'omp-chips'
|
||||
| 'omp-cinnamon'
|
||||
| 'omp-clean-detailed'
|
||||
| 'omp-cloud-context'
|
||||
| 'omp-cloud-native-azure'
|
||||
| 'omp-cobalt2'
|
||||
| 'omp-craver'
|
||||
| 'omp-darkblood'
|
||||
| 'omp-devious-diamonds'
|
||||
| 'omp-di4am0nd'
|
||||
| 'omp-dracula'
|
||||
| 'omp-easy-term'
|
||||
| 'omp-emodipt'
|
||||
| 'omp-emodipt-extend'
|
||||
| 'omp-fish'
|
||||
| 'omp-free-ukraine'
|
||||
| 'omp-froczh'
|
||||
| 'omp-gmay'
|
||||
| 'omp-glowsticks'
|
||||
| 'omp-grandpa-style'
|
||||
| 'omp-gruvbox'
|
||||
| 'omp-half-life'
|
||||
| 'omp-honukai'
|
||||
| 'omp-hotstick.minimal'
|
||||
| 'omp-hul10'
|
||||
| 'omp-hunk'
|
||||
| 'omp-huvix'
|
||||
| 'omp-if_tea'
|
||||
| 'omp-illusi0n'
|
||||
| 'omp-iterm2'
|
||||
| 'omp-jandedobbeleer'
|
||||
| 'omp-jblab_2021'
|
||||
| 'omp-jonnychipz'
|
||||
| 'omp-json'
|
||||
| 'omp-jtracey93'
|
||||
| 'omp-jv_sitecorian'
|
||||
| 'omp-kali'
|
||||
| 'omp-kushal'
|
||||
| 'omp-lambda'
|
||||
| 'omp-lambdageneration'
|
||||
| 'omp-larserikfinholt'
|
||||
| 'omp-lightgreen'
|
||||
| 'omp-M365Princess'
|
||||
| 'omp-marcduiker'
|
||||
| 'omp-markbull'
|
||||
| 'omp-material'
|
||||
| 'omp-microverse-power'
|
||||
| 'omp-mojada'
|
||||
| 'omp-montys'
|
||||
| 'omp-mt'
|
||||
| 'omp-multiverse-neon'
|
||||
| 'omp-negligible'
|
||||
| 'omp-neko'
|
||||
| 'omp-night-owl'
|
||||
| 'omp-nordtron'
|
||||
| 'omp-nu4a'
|
||||
| 'omp-onehalf.minimal'
|
||||
| 'omp-paradox'
|
||||
| 'omp-pararussel'
|
||||
| 'omp-patriksvensson'
|
||||
| 'omp-peru'
|
||||
| 'omp-pixelrobots'
|
||||
| 'omp-plague'
|
||||
| 'omp-poshmon'
|
||||
| 'omp-powerlevel10k_classic'
|
||||
| 'omp-powerlevel10k_lean'
|
||||
| 'omp-powerlevel10k_modern'
|
||||
| 'omp-powerlevel10k_rainbow'
|
||||
| 'omp-powerline'
|
||||
| 'omp-probua.minimal'
|
||||
| 'omp-pure'
|
||||
| 'omp-quick-term'
|
||||
| 'omp-remk'
|
||||
| 'omp-robbyrussell'
|
||||
| 'omp-rudolfs-dark'
|
||||
| 'omp-rudolfs-light'
|
||||
| 'omp-sim-web'
|
||||
| 'omp-slim'
|
||||
| 'omp-slimfat'
|
||||
| 'omp-smoothie'
|
||||
| 'omp-sonicboom_dark'
|
||||
| 'omp-sonicboom_light'
|
||||
| 'omp-sorin'
|
||||
| 'omp-space'
|
||||
| 'omp-spaceship'
|
||||
| 'omp-star'
|
||||
| 'omp-stelbent-compact.minimal'
|
||||
| 'omp-stelbent.minimal'
|
||||
| 'omp-takuya'
|
||||
| 'omp-the-unnamed'
|
||||
| 'omp-thecyberden'
|
||||
| 'omp-tiwahu'
|
||||
| 'omp-tokyo'
|
||||
| 'omp-tokyonight_storm'
|
||||
| 'omp-tonybaloney'
|
||||
| 'omp-uew'
|
||||
| 'omp-unicorn'
|
||||
| 'omp-velvet'
|
||||
| 'omp-wholespace'
|
||||
| 'omp-wopian'
|
||||
| 'omp-xtoys'
|
||||
| 'omp-ys'
|
||||
| 'omp-zash';
|
||||
|
||||
/** PlanningMode - Planning levels for feature generation workflows */
|
||||
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
@@ -840,6 +977,39 @@ export interface GlobalSettings {
|
||||
// Terminal Configuration
|
||||
/** How to open terminals from "Open in Terminal" worktree action */
|
||||
openTerminalMode?: 'newTab' | 'split';
|
||||
/** Custom terminal configuration settings (prompt theming, aliases, env vars) */
|
||||
terminalConfig?: {
|
||||
/** Enable custom terminal configurations (default: false) */
|
||||
enabled: boolean;
|
||||
/** Enable custom prompt (default: true when enabled) */
|
||||
customPrompt: boolean;
|
||||
/** Prompt format template */
|
||||
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
/** Prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
/** Show git branch in prompt (default: true) */
|
||||
showGitBranch: boolean;
|
||||
/** Show git status dirty indicator (default: true) */
|
||||
showGitStatus: boolean;
|
||||
/** Show user and host in prompt (default: true) */
|
||||
showUserHost: boolean;
|
||||
/** Show path in prompt (default: true) */
|
||||
showPath: boolean;
|
||||
/** Path display style */
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
/** Limit path depth (0 = full path) */
|
||||
pathDepth: number;
|
||||
/** Show current time in prompt (default: false) */
|
||||
showTime: boolean;
|
||||
/** Show last command exit status when non-zero (default: false) */
|
||||
showExitStatus: boolean;
|
||||
/** User-provided custom aliases (multiline string) */
|
||||
customAliases: string;
|
||||
/** User-provided custom env vars */
|
||||
customEnvVars: Record<string, string>;
|
||||
/** RC file format version (for migration) */
|
||||
rcFileVersion?: number;
|
||||
};
|
||||
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
@@ -1245,6 +1415,33 @@ export interface ProjectSettings {
|
||||
*/
|
||||
defaultFeatureModel?: PhaseModelEntry;
|
||||
|
||||
// Terminal Configuration Override (per-project)
|
||||
/** Project-specific terminal config overrides */
|
||||
terminalConfig?: {
|
||||
/** Override global enabled setting */
|
||||
enabled?: boolean;
|
||||
/** Override prompt theme preset */
|
||||
promptTheme?: TerminalPromptTheme;
|
||||
/** Override showing user/host */
|
||||
showUserHost?: boolean;
|
||||
/** Override showing path */
|
||||
showPath?: boolean;
|
||||
/** Override path style */
|
||||
pathStyle?: 'full' | 'short' | 'basename';
|
||||
/** Override path depth (0 = full path) */
|
||||
pathDepth?: number;
|
||||
/** Override showing time */
|
||||
showTime?: boolean;
|
||||
/** Override showing exit status */
|
||||
showExitStatus?: boolean;
|
||||
/** Project-specific custom aliases */
|
||||
customAliases?: string;
|
||||
/** Project-specific env vars */
|
||||
customEnvVars?: Record<string, string>;
|
||||
/** Custom welcome message for this project */
|
||||
welcomeMessage?: string;
|
||||
};
|
||||
|
||||
// Deprecated Claude API Profile Override
|
||||
/**
|
||||
* @deprecated Use phaseModelOverrides instead.
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"prettier": "3.7.4",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"prettier": "3.7.4",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,11 @@ set -e
|
||||
# ============================================================================
|
||||
# CONFIGURATION & CONSTANTS
|
||||
# ============================================================================
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
APP_NAME="Automaker"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HISTORY_FILE="${HOME}/.automaker_launcher_history"
|
||||
@@ -1154,7 +1158,9 @@ fi
|
||||
# Execute the appropriate command
|
||||
case $MODE in
|
||||
web)
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
export TEST_PORT="$WEB_PORT"
|
||||
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
|
||||
export PORT="$SERVER_PORT"
|
||||
|
||||
Reference in New Issue
Block a user